diff --git a/web-ui/app/_components/Markdown.tsx b/web-ui/app/_components/Markdown.tsx
index daa18802..f2a96fc2 100644
--- a/web-ui/app/_components/Markdown.tsx
+++ b/web-ui/app/_components/Markdown.tsx
@@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { stripCitationMarkers } from '../_lib/citations';
+import { MarkdownTable } from './MarkdownTable';
/**
* Thin wrapper that renders GitHub-flavored markdown with our project-local
@@ -117,9 +118,26 @@ export function Markdown({
const displayed = useMemo(() => stripCitationMarkers(source), [source]);
return (
-
+
{displayed}
);
}
+
+/**
+ * GFM table renderer override. Wraps every table in a horizontal/vertical
+ * scroll container — see {@link MarkdownTable}. Hoisted to module scope so
+ * the components object is stable across re-renders (no needless tree
+ * recomputation during streaming).
+ */
+const MARKDOWN_COMPONENTS: ComponentProps['components'] =
+ {
+ table: ({ children, className }) => (
+ {children}
+ ),
+ };
diff --git a/web-ui/app/_components/MarkdownTable.tsx b/web-ui/app/_components/MarkdownTable.tsx
new file mode 100644
index 00000000..bb8b3067
--- /dev/null
+++ b/web-ui/app/_components/MarkdownTable.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { useLayoutEffect, useRef, useState } from 'react';
+import { useTranslations } from 'next-intl';
+
+interface TableProps {
+ children?: ReactNode;
+ className?: string;
+}
+
+/**
+ * Custom `` renderer for {@link Markdown}. Wraps the GFM table in a
+ * scroll-container so very wide / very long tables stay inside their
+ * surrounding block: horizontal scroll for many columns or long
+ * compound-word cells, vertical scroll with a sticky `` for many
+ * rows. The wrapper is keyboard-focusable so arrow-key scrolling works
+ * for keyboard-only users.
+ *
+ * The scroll-region affordance (focusable + labelled group) is only applied
+ * when the wrapper actually overflows, so a table that fits its container
+ * doesn't consume a tab stop or announce a redundant group (WCAG 2.1.1).
+ */
+export function MarkdownTable({
+ children,
+ className,
+}: TableProps): React.ReactElement {
+ const t = useTranslations('markdownTable');
+ const ref = useRef(null);
+ const [overflows, setOverflows] = useState(false);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const measure = (): void =>
+ setOverflows(
+ el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight,
+ );
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+ return (
+
+ );
+}
diff --git a/web-ui/app/_components/__tests__/Markdown.test.tsx b/web-ui/app/_components/__tests__/Markdown.test.tsx
index 44541926..b8480db2 100644
--- a/web-ui/app/_components/__tests__/Markdown.test.tsx
+++ b/web-ui/app/_components/__tests__/Markdown.test.tsx
@@ -1,6 +1,6 @@
-import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
+import { renderWithIntl } from '../../_lib/test-utils';
import { Markdown } from '../Markdown';
/**
@@ -10,7 +10,7 @@ import { Markdown } from '../Markdown';
*/
describe(' — Privacy Shield v4 highlight', () => {
it('wraps a highlightTerm occurrence in a violet span', () => {
- const { container } = render(
+ const { container } = renderWithIntl(
— Privacy Shield v4 highlight', () => {
});
it('does not highlight anything when no terms are given', () => {
- const { container } = render(
+ const { container } = renderWithIntl(
,
);
expect(container.querySelector('span[class*="bg-[color:var(--accent)]/10"]')).toBeNull();
@@ -30,7 +30,7 @@ describe(' — Privacy Shield v4 highlight', () => {
});
it('highlights every occurrence and leaves surrounding text intact', () => {
- const { container } = render(
+ const { container } = renderWithIntl(
— Privacy Shield v4 highlight', () => {
});
it('ignores empty / whitespace-only terms', () => {
- const { container } = render(
+ const { container } = renderWithIntl(
,
);
expect(container.querySelector('span[class*="bg-[color:var(--accent)]/10"]')).toBeNull();
diff --git a/web-ui/app/_components/__tests__/MarkdownTable.test.tsx b/web-ui/app/_components/__tests__/MarkdownTable.test.tsx
new file mode 100644
index 00000000..0ec9ac93
--- /dev/null
+++ b/web-ui/app/_components/__tests__/MarkdownTable.test.tsx
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+
+import { renderWithIntl } from '../../_lib/test-utils';
+import { MarkdownTable } from '../MarkdownTable';
+
+/**
+ * MarkdownTable wraps the GFM `` in a focusable scroll container.
+ * Asserts the wrapper contract: container class, accessibility attrs from
+ * the i18n key, and that the inner table receives the className passed by
+ * the react-markdown override slot.
+ */
+describe('', () => {
+ it('wraps children in a .md-table-wrap div with the inner table', () => {
+ const { container } = renderWithIntl(
+
+
+
+ | cell |
+
+
+ ,
+ );
+ const wrap = container.querySelector('div.md-table-wrap');
+ expect(wrap).not.toBeNull();
+ expect(wrap?.querySelector('table')).not.toBeNull();
+ });
+
+ it('omits the scroll-region affordance when the table fits its container', () => {
+ // jsdom reports 0 for all scroll/client dims, so the wrapper never
+ // overflows: no tab stop, no group role, no aria-label (WCAG 2.1.1).
+ const { container } = renderWithIntl(
+
+
+
+ | cell |
+
+
+ ,
+ );
+ const wrap = container.querySelector('div.md-table-wrap');
+ expect(wrap?.getAttribute('role')).toBeNull();
+ expect(wrap?.getAttribute('tabindex')).toBeNull();
+ expect(wrap?.getAttribute('aria-label')).toBeNull();
+ });
+
+ it('exposes a focusable group with an i18n aria-label when the table overflows', () => {
+ // Force horizontal overflow: scrollWidth > clientWidth at mount.
+ const proto = window.HTMLElement.prototype;
+ const sw = Object.getOwnPropertyDescriptor(proto, 'scrollWidth');
+ const cw = Object.getOwnPropertyDescriptor(proto, 'clientWidth');
+ Object.defineProperty(proto, 'scrollWidth', { configurable: true, value: 200 });
+ Object.defineProperty(proto, 'clientWidth', { configurable: true, value: 100 });
+ try {
+ const { container } = renderWithIntl(
+
+
+
+ | cell |
+
+
+ ,
+ );
+ const wrap = container.querySelector('div.md-table-wrap');
+ expect(wrap?.getAttribute('role')).toBe('group');
+ expect(wrap?.getAttribute('tabindex')).toBe('0');
+ expect(wrap?.getAttribute('aria-label')).toBe('Scrollable table');
+ } finally {
+ if (sw) Object.defineProperty(proto, 'scrollWidth', sw);
+ else delete (proto as { scrollWidth?: unknown }).scrollWidth;
+ if (cw) Object.defineProperty(proto, 'clientWidth', cw);
+ else delete (proto as { clientWidth?: unknown }).clientWidth;
+ }
+ });
+
+ it('forwards className to the inner ', () => {
+ const { container } = renderWithIntl(
+
+
+
+ | cell |
+
+
+ ,
+ );
+ const table = container.querySelector('table');
+ expect(table?.className).toBe('custom-class');
+ });
+});
diff --git a/web-ui/app/globals.css b/web-ui/app/globals.css
index dad3b5b0..6401c157 100644
--- a/web-ui/app/globals.css
+++ b/web-ui/app/globals.css
@@ -480,18 +480,77 @@ body {
@apply font-semibold text-[color:var(--fg-strong)];
}
+/* Tables live inside a scroll-wrapper (MarkdownTable) so very wide tables
+ scroll horizontally inside the chat bubble instead of bleeding past it.
+ The wrapper also gives a max-height + sticky thead for long tables. The
+ `min-width: max-content` keeps columns from being squeezed to unreadable
+ widths — the wrapper takes the scroll instead. */
+.md-table-wrap {
+ @apply relative my-3 overflow-auto rounded-md;
+ max-width: 100%;
+ max-height: 60vh;
+ border: 1px solid var(--border);
+}
.md-view table {
- @apply my-3 w-full border-collapse text-xs;
+ @apply my-3 w-full text-xs;
+ border-collapse: collapse;
}
.md-view th,
.md-view td {
@apply px-3 py-2 text-left align-top tabular-nums;
border: 1px solid var(--border);
+ overflow-wrap: break-word;
}
.md-view th {
@apply font-semibold;
background: var(--bg-soft);
}
+/* Inside the scroll wrapper we switch to `border-collapse: separate` so cell
+ borders stay on the cell — required for the sticky `` divider to
+ ride along with the scroll. `min-width: max-content` keeps wide tables from
+ being squeezed (the wrapper takes the horizontal scroll instead); `width:
+ 100%` makes small tables fill the wrapper so its border/rounded corners
+ don't gap. Margin is zeroed because the wrapper itself supplies `my-3`.
+ Outside the wrapper (e.g. BuilderMarkdown) the `.md-view table` defaults
+ above stay in force — no sticky header, classic collapse model. */
+.md-table-wrap table {
+ border-collapse: separate;
+ border-spacing: 0;
+ min-width: max-content;
+ width: 100%;
+ margin: 0;
+}
+.md-table-wrap th,
+.md-table-wrap td {
+ border: 0;
+ border-right: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+}
+.md-table-wrap th:first-child,
+.md-table-wrap td:first-child {
+ border-left: 1px solid var(--border);
+}
+/* `border-collapse: separate` keeps cell borders on the cell, so a sticky
+ header keeps its divider while scrolling. We still render the bottom rule
+ as an inset shadow (instead of `border-bottom`) so it's painted by the
+ sticky cell itself — guaranteed visible regardless of how the row above
+ resolves the shared edge. */
+.md-table-wrap thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ border-bottom: 0;
+ box-shadow: inset 0 -1px 0 var(--border);
+}
+@media print {
+ .md-table-wrap {
+ overflow: visible;
+ max-height: none;
+ }
+ .md-table-wrap thead th {
+ position: static;
+ }
+}
.md-view hr {
@apply my-3;
border-color: var(--divider);
diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json
index ac60ee99..e735917a 100644
--- a/web-ui/messages/de.json
+++ b/web-ui/messages/de.json
@@ -578,6 +578,9 @@
"outputLabel": "Output",
"subIterations": "{count} interne Iterationen"
},
+ "markdownTable": {
+ "scrollRegionLabel": "Scrollbare Tabelle"
+ },
"nudgeCard": {
"kicker": "Vorschlag",
"ackTriggered": "✓ ausgelöst",
diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json
index 78373813..a71e324c 100644
--- a/web-ui/messages/en.json
+++ b/web-ui/messages/en.json
@@ -578,6 +578,9 @@
"outputLabel": "Output",
"subIterations": "{count} inner iterations"
},
+ "markdownTable": {
+ "scrollRegionLabel": "Scrollable table"
+ },
"nudgeCard": {
"kicker": "Suggestion",
"ackTriggered": "✓ triggered",
diff --git a/web-ui/vitest.setup.ts b/web-ui/vitest.setup.ts
index bb02c60c..ce20bddd 100644
--- a/web-ui/vitest.setup.ts
+++ b/web-ui/vitest.setup.ts
@@ -1 +1,11 @@
import '@testing-library/jest-dom/vitest';
+
+// jsdom has no ResizeObserver; stub it so components that observe element
+// size (e.g. MarkdownTable's overflow detection) can mount in tests.
+if (!('ResizeObserver' in globalThis)) {
+ globalThis.ResizeObserver = class {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+ };
+}