Skip to content
Closed
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
119 changes: 37 additions & 82 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ import {
InfoIcon,
LightbulbIcon,
MailIcon,
Maximize2Icon,
MessageSquareIcon,
MessageSquareWarningIcon,
Minimize2Icon,
OctagonAlertIcon,
PresentationIcon,
SparklesIcon,
Expand Down Expand Up @@ -698,39 +696,17 @@ function readInitialWordWrapSetting(): boolean {
return getClientSettings().wordWrap;
}

/**
* Renders a markdown table with a copy-as-Markdown/CSV footer. Inside a
* `.chat-markdown-wide-tables` root the frame can grow past the reading
* column (see index.css); otherwise it scrolls within the column.
*/
function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
const containerRef = useRef<HTMLDivElement | null>(null);
const tableRef = useRef<HTMLTableElement | null>(null);
const [expanded, setExpanded] = useState(readInitialWordWrapSetting);
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const expandLabel = expanded ? "Collapse table cells" : "Expand table cells";
const copyLabel = copied ? "Copied" : "Copy table";

function toggleExpanded() {
const table = tableRef.current;
if (!table) return;

if (!expanded) {
const rows = [...table.rows];
const columnWidths = rows.reduce<number[]>((widths, row) => {
[...row.cells].forEach((cell, columnIndex) => {
widths[columnIndex] = Math.max(
widths[columnIndex] ?? 0,
cell.getBoundingClientRect().width,
);
});
return widths;
}, []);

[...(table.tHead?.rows[0]?.cells ?? [])].forEach((cell, columnIndex) => {
cell.style.minWidth = `${columnWidths[columnIndex] ?? cell.getBoundingClientRect().width}px`;
});
}

setExpanded((value) => !value);
}

const handleCopy = useCallback((format: "markdown" | "csv") => {
const table = containerRef.current?.querySelector("table");
if (!table || typeof navigator === "undefined" || navigator.clipboard == null) {
Expand Down Expand Up @@ -768,59 +744,38 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
);

return (
<div
ref={containerRef}
className="chat-markdown-table-container"
data-expanded={expanded ? "true" : "false"}
>
<ScrollArea radius="none" chainVerticalScroll scrollFade className="w-full max-w-full">
<table ref={tableRef} {...props}>
{children}
</table>
</ScrollArea>
<div className="mt-0.5 flex items-center justify-between select-none">
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant={expanded ? "secondary" : "ghost-muted"}
size="icon-xs"
aria-pressed={expanded}
onClick={toggleExpanded}
aria-label={expandLabel}
/>
}
>
{expanded ? <Minimize2Icon className="size-3" /> : <Maximize2Icon className="size-3" />}
</TooltipTrigger>
<TooltipPopup side="top">{expandLabel}</TooltipPopup>
</Tooltip>
<Menu>
<Tooltip>
<TooltipTrigger
render={
<MenuTrigger
render={
<Button
type="button"
variant="ghost-muted"
size="icon-xs"
aria-label={copyLabel}
/>
}
/>
}
>
{copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
</TooltipTrigger>
<TooltipPopup side="top">{copyLabel}</TooltipPopup>
</Tooltip>
<MenuPopup align="end">
<MenuItem onClick={() => handleCopy("markdown")}>Copy as Markdown</MenuItem>
<MenuItem onClick={() => handleCopy("csv")}>Copy as CSV</MenuItem>
</MenuPopup>
</Menu>
<div ref={containerRef} className="chat-markdown-table-container">
<div className="chat-markdown-table-frame">
<ScrollArea radius="none" chainVerticalScroll scrollFade className="w-full max-w-full">
<table {...props}>{children}</table>
</ScrollArea>
<div className="mt-0.5 flex items-center justify-end select-none">
<Menu>
<Tooltip>
<TooltipTrigger
render={
<MenuTrigger
render={
<Button
type="button"
variant="ghost-muted"
size="icon-xs"
aria-label={copyLabel}
/>
}
/>
}
>
{copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
</TooltipTrigger>
<TooltipPopup side="top">{copyLabel}</TooltipPopup>
</Tooltip>
<MenuPopup align="end">
<MenuItem onClick={() => handleCopy("markdown")}>Copy as Markdown</MenuItem>
<MenuItem onClick={() => handleCopy("csv")}>Copy as CSV</MenuItem>
</MenuPopup>
</Menu>
</div>
</div>
</div>
);
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1234,7 +1234,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
// from TimelineRowCtx, which propagates through LegendList's memo.
const renderItem = useCallback(
({ item }: { item: MessagesTimelineRow }) => (
<div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-clip" data-timeline-root="true">
<div className="mx-auto w-full min-w-0 max-w-3xl" data-timeline-root="true">
<TimelineRowContent row={item} />
</div>
),
Expand Down Expand Up @@ -1305,7 +1305,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onScroll={handleScroll}
onItemSizeChanged={reportContentOverflow}
className={cn(
"scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
"@container/chat-timeline scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
topFadeEnabled && "topbar-scroll-fade",
)}
ListHeaderComponent={
Expand Down Expand Up @@ -2348,6 +2348,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
);
}

/**
* An assistant reply in the timeline. Its markdown opts into
* `chat-markdown-wide-tables`, so top-level tables may widen past the column.
*/
function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
const ctx = use(TimelineRowCtx);
const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
Expand All @@ -2364,6 +2368,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
listRef={ctx.listRef}
>
<ChatMarkdown
className="chat-markdown-wide-tables"
text={messageText}
cwd={ctx.markdownCwd}
threadRef={ctx.threadRef ?? undefined}
Expand Down
32 changes: 21 additions & 11 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1915,23 +1915,33 @@ code {
white-space: nowrap;
}

/* Long cell content wraps at 24rem so one verbose cell cannot blow a column
out to the full pane width. */
.chat-markdown tbody td {
max-width: 24rem;
border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent);
overflow-wrap: anywhere;
}

/* Collapsed (default): single-line cells truncate so arbitrary chat content
cannot blow the column out; the footer toggle expands cells to wrap. */
.chat-markdown .chat-markdown-table-container[data-expanded="false"] th,
.chat-markdown .chat-markdown-table-container[data-expanded="false"] td {
overflow: hidden;
max-width: 24rem;
text-overflow: ellipsis;
white-space: nowrap;
/* Assistant replies in the timeline: a top-level table wider than the reading
column grows into the pane's side gutters instead of scrolling. The flex
parent centers the frame so it overflows the column evenly on both sides;
width is capped at 64rem (the column is 48rem) and to the timeline
container (`@container/chat-timeline`) minus the minimap gutter; the
ScrollArea scrolls past that. */
.chat-markdown-wide-tables > .chat-markdown-table-container {
display: flex;
/* Not stretch: a stretched item gets a definite height, which makes the
ScrollArea's `size-full` swallow the footer. */
align-items: flex-start;
justify-content: center;
}

.chat-markdown .chat-markdown-table-container[data-expanded="true"] td {
max-width: 24rem;
overflow-wrap: anywhere;
.chat-markdown-wide-tables > .chat-markdown-table-container > .chat-markdown-table-frame {
flex: none;
width: max-content;
min-width: 100%;
max-width: max(100%, min(64rem, calc(100cqw - 6.5rem)));
}

/* Prompt-stash save acknowledgement: the new count fades up from just below
Expand Down
Loading