Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions gui/src/components/chat/markdown/ChatTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test, expect } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";

import { ChatTable, chatTableLayoutFor } from "./ChatTable";
import { MarkdownRenderer } from "./MarkdownRenderer";

const header = ["Impact", "Site"];
const rows = [
["high", "repl.zig:700"],
["medium", "main.zig:14048"],
];

test("wide layout renders a real table", () => {
const html = renderToStaticMarkup(
<ChatTable header={header} align={[null, null]} rows={rows} forceLayout="table" />,
);
expect(html).toContain("<table");
expect(html).toContain("repl.zig:700");
});

test("records layout renders one card per row, repeating header labels", () => {
const html = renderToStaticMarkup(
<ChatTable header={header} align={[null, null]} rows={rows} forceLayout="records" />,
);
expect(html).not.toContain("<table");
expect(html).toContain("<dl");
expect(html.split("Impact").length - 1).toBe(2); // one label per record
expect(html).toContain("main.zig:14048");
});

test("layout decision flips to records only when columns get cramped", () => {
expect(chatTableLayoutFor(800, 4)).toBe("table");
expect(chatTableLayoutFor(300, 4)).toBe("records");
expect(chatTableLayoutFor(0, 4)).toBe("table"); // unmeasured → default wide
});

test("markdown pipe tables flow through ChatTable (SSR default = table)", () => {
const html = renderToStaticMarkup(
<MarkdownRenderer text={"| A | B |\n|---|---|\n| 1 | 2 |"} />,
);
expect(html).toContain("<table");
expect(html).toContain("<th");
});
121 changes: 121 additions & 0 deletions gui/src/components/chat/markdown/ChatTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useLayoutEffect, useRef, useState } from "react";
import type { ReactNode } from "react";

import { cn } from "@/utils/cn";
import type { TableAlign } from "./types";

// Below this many px per column the grid form is cramped past readability —
// the same judgement call as the TUI renderer's per-column floor.
const MIN_COL_PX = 96;

export type ChatTableLayout = "table" | "records";

export function chatTableLayoutFor(
containerPx: number,
columnCount: number,
): ChatTableLayout {
return containerPx > 0 && containerPx < columnCount * MIN_COL_PX
? "records"
: "table";
}

interface ChatTableProps {
header: ReactNode[];
align: TableAlign[];
rows: ReactNode[][];
/** Test/SSR escape hatch; normally decided by measuring the container. */
forceLayout?: ChatTableLayout;
}

/**
* Adaptive chat table: a regular grid table when the pane is wide enough,
* collapsing to one record card per row (`Label: value` lines) when it isn't.
* Mirrors the TUI renderer's box-drawing table and its narrow record fallback
* (src/repl.zig renderTable/renderRecords), so both surfaces degrade the same
* way. Chat tiles resize independently of the window (dockview), so the
* decision measures the container, not the viewport.
*/
export function ChatTable({ header, align, rows, forceLayout }: ChatTableProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [measured, setMeasured] = useState<ChatTableLayout>("table");
const layout = forceLayout ?? measured;

useLayoutEffect(() => {
if (forceLayout || typeof ResizeObserver === "undefined") return;
const el = containerRef.current;
if (!el) return;
const update = () =>
setMeasured(chatTableLayoutFor(el.clientWidth, header.length));
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [forceLayout, header.length]);

const alignClass = (a: TableAlign) =>
a === "center" ? "text-center" : a === "right" ? "text-right" : undefined;

if (layout === "records") {
return (
<div
ref={containerRef}
className="flex max-w-full flex-col divide-y divide-border/70 rounded-md border border-border/70"
>
{rows.map((row, rowIndex) => (
<dl key={rowIndex} className="m-0 flex flex-col gap-1 px-3 py-2 text-sm">
{row.map((cell, cellIndex) => (
<div key={cellIndex} className="flex min-w-0 gap-2">
<dt className="shrink-0 pt-px text-xs font-medium uppercase tracking-normal text-muted-foreground">
{header[cellIndex]}
</dt>
<dd className="m-0 min-w-0 break-words">{cell}</dd>
</div>
))}
</dl>
))}
</div>
);
}

return (
<div
ref={containerRef}
className="max-w-full overflow-x-auto rounded-md border border-border/70"
>
<table className="w-full border-collapse text-left text-sm">
<thead>
<tr>
{header.map((cell, index) => (
<th
key={index}
className={cn(
"bg-muted/55 px-3 py-2 text-xs font-medium uppercase tracking-normal text-muted-foreground",
alignClass(align[index]),
)}
>
{cell}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
className={cn(
"border-t border-border/70 px-3 py-2 align-top",
alignClass(align[cellIndex]),
)}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
56 changes: 12 additions & 44 deletions gui/src/components/chat/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import {
sanitizeChatMarkdownHref,
} from "../utils/chatLinks";
import { openFilePathFromChat, openUrlFromChat } from "../utils/chatOpen";
import { ChatTable } from "./ChatTable";
import { CodeBlock } from "./CodeBlock";
import { MermaidDiagram } from "./MermaidDiagram";
import { dropRedundantCodeHeadings, parseMarkdown } from "./parser";
import type { MdBlock, MdInline, MdListItem, TableAlign } from "./types";
import type { MdBlock, MdInline, MdListItem } from "./types";

const headingClassName = cn("m-0 font-medium text-current", CHAT_BODY_TEXT_CLASS);

Expand Down Expand Up @@ -111,50 +112,17 @@ function renderTable(
block: Extract<MdBlock, { type: "table" }>,
workspacePath?: string | null,
): ReactNode {
const alignClass = (align: TableAlign) =>
align === "center" ? "text-center" : align === "right" ? "text-right" : undefined;

const cellNode = (cell: MdInline[]) => (
<ChatInlineChildren workspacePath={workspacePath}>
{renderInline(cell, workspacePath)}
</ChatInlineChildren>
);
return (
<div className="max-w-full overflow-x-auto rounded-md border border-border/70">
<table className="w-full border-collapse text-left text-sm">
<thead>
<tr>
{block.header.map((cell, index) => (
<th
key={index}
className={cn(
"bg-muted/55 px-3 py-2 text-xs font-medium uppercase tracking-normal text-muted-foreground",
alignClass(block.align[index]),
)}
>
<ChatInlineChildren workspacePath={workspacePath}>
{renderInline(cell, workspacePath)}
</ChatInlineChildren>
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
className={cn(
"border-t border-border/70 px-3 py-2 align-top",
alignClass(block.align[cellIndex]),
)}
>
<ChatInlineChildren workspacePath={workspacePath}>
{renderInline(cell, workspacePath)}
</ChatInlineChildren>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<ChatTable
header={block.header.map(cellNode)}
align={block.align}
rows={block.rows.map((row) => row.map(cellNode))}
/>
);
}

Expand Down
Loading
Loading