Skip to content

Commit deaa444

Browse files
refactor(web): chat markdown tables and details live in their own components
MarkdownTable, with its clipped-cell measuring and copy menu, moves to chat/MarkdownTable.tsx; the collapsible details block moves to chat/MarkdownDetails.tsx. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 8399708 commit deaa444

3 files changed

Lines changed: 236 additions & 220 deletions

File tree

‎apps/web/src/components/ChatMarkdown.tsx‎

Lines changed: 4 additions & 220 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions";
2-
import { MarkdownFindContext, findRevealRef } from "./chat/markdownFindContext";
32
import {
43
buildFileLinkParentSuffixByPath,
54
fileLinkLabel,
@@ -26,7 +25,6 @@ import {
2625
encodeComposerContextClipboardHtml,
2726
} from "@t3tools/shared/composerContextClipboard";
2827
import {
29-
ChevronRightIcon,
3028
FileSpreadsheetIcon,
3129
FileTextIcon,
3230
GlobeIcon,
@@ -42,7 +40,6 @@ import {
4240
TriangleAlertIcon,
4341
type LucideIcon,
4442
} from "lucide-react";
45-
import { Check, Copy, Maximize2, Minimize2 } from "lucide";
4643
import {
4744
AssetWorkspaceAssetNotFoundError,
4845
AuthPreviewOperateScope,
@@ -85,7 +82,6 @@ import React, {
8582
type ClipboardEvent as ReactClipboardEvent,
8683
type KeyboardEvent as ReactKeyboardEvent,
8784
type MouseEvent as ReactMouseEvent,
88-
isValidElement,
8985
use,
9086
memo,
9187
useEffect,
@@ -136,11 +132,7 @@ import {
136132
} from "./chat/externalLinkContextMenu";
137133
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
138134
import { Button } from "./ui/button";
139-
import { MorphIcon } from "~/components/MorphIcon";
140135
import { ContextChip } from "./ContextChip";
141-
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible";
142-
import { ScrollArea } from "./ui/scroll-area";
143-
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
144136
import { stackedThreadToast, toastManager } from "./ui/toast";
145137
import { recordVisitForThread } from "../browserHistoryStore";
146138
import {
@@ -153,12 +145,8 @@ import { resolveDiffThemeName } from "../lib/diffRendering";
153145
import { GitHubIcon } from "./Icons";
154146
import { RenderErrorBoundary } from "./RenderErrorBoundary";
155147
import { useRenderedAppearance } from "../hooks/useTheme";
156-
import { getClientSettings, useClientSettings } from "../hooks/useSettings";
157-
import {
158-
chatMarkdownClipboardPayload,
159-
serializeTableElementToCsv,
160-
serializeTableElementToMarkdown,
161-
} from "../markdown-clipboard";
148+
import { useClientSettings } from "../hooks/useSettings";
149+
import { chatMarkdownClipboardPayload } from "../markdown-clipboard";
162150
import {
163151
resolveInlineCodeFileLinkMeta,
164152
resolveMarkdownFileLinkMeta,
@@ -212,7 +200,6 @@ import {
212200
resolvePullRequestPreviewTarget,
213201
useOpenChangeRequestLink,
214202
} from "~/lib/openPullRequestLink";
215-
import { observeResize } from "~/lib/observeResize";
216203
import { useOpenLink } from "../browser/useOpenLink";
217204
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
218205
import { isPreviewAvailableFor } from "../browser/previewRuntime";
@@ -227,6 +214,8 @@ import { resolveLinkTarget } from "../browser/browserLinkTarget";
227214
import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview";
228215
import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock";
229216
import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock";
217+
import { MarkdownTable } from "./chat/MarkdownTable";
218+
import { MarkdownDetails } from "./chat/MarkdownDetails";
230219
import {
231220
extractCodeBlock,
232221
extractFenceLanguage,
@@ -450,211 +439,6 @@ const GITHUB_ALERT_PRESENTATIONS: Record<
450439
},
451440
};
452441

453-
/**
454-
* Whether any cell is clipped in the unwrapped layout. Only then does the wrap
455-
* toggle change anything, so short tables hide it. Measures with the container
456-
* briefly unwrapped and restores it before the browser paints.
457-
*/
458-
function tableHasClippedCells(container: HTMLElement, table: HTMLTableElement): boolean {
459-
const viewport = table.closest<HTMLElement>('[data-slot="scroll-area-viewport"]');
460-
const scrollLeft = viewport?.scrollLeft ?? 0;
461-
const previous = container.dataset.expanded;
462-
container.dataset.expanded = "false";
463-
try {
464-
for (const row of table.rows) {
465-
for (const cell of row.cells) {
466-
if (cell.scrollWidth > cell.clientWidth) return true;
467-
}
468-
}
469-
return false;
470-
} finally {
471-
if (previous === undefined) delete container.dataset.expanded;
472-
else container.dataset.expanded = previous;
473-
// The unwrapped layout can be narrower and clamp a sideways scroll.
474-
if (viewport && scrollLeft > 0) viewport.scrollLeft = scrollLeft;
475-
}
476-
}
477-
478-
function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
479-
const containerRef = useRef<HTMLDivElement | null>(null);
480-
const tableRef = useRef<HTMLTableElement | null>(null);
481-
const [expanded, setExpanded] = useState(() => getClientSettings().wordWrap);
482-
const [hasClippedCells, setHasClippedCells] = useState(false);
483-
const [copied, setCopied] = useState(false);
484-
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
485-
const expandLabel = expanded ? "Collapse table cells" : "Expand table cells";
486-
const copyLabel = copied ? "Copied" : "Copy table";
487-
488-
const measureClippedCells = () => {
489-
const container = containerRef.current;
490-
const table = tableRef.current;
491-
if (!container || !table) return;
492-
setHasClippedCells(tableHasClippedCells(container, table));
493-
};
494-
495-
// A streamed cell can grow until it clips while the table keeps its size,
496-
// so remeasure after every render, not only on resize.
497-
useLayoutEffect(measureClippedCells);
498-
499-
// Column width changes can clip a cell without rendering this table.
500-
useLayoutEffect(() => {
501-
const table = tableRef.current;
502-
if (!table) return;
503-
return observeResize(table, measureClippedCells);
504-
}, [measureClippedCells]);
505-
506-
const handleCopy = (format: "markdown" | "csv") => {
507-
const table = containerRef.current?.querySelector("table");
508-
if (!table || typeof navigator === "undefined" || navigator.clipboard == null) {
509-
return;
510-
}
511-
const text =
512-
format === "markdown"
513-
? serializeTableElementToMarkdown(table)
514-
: serializeTableElementToCsv(table);
515-
void navigator.clipboard
516-
.writeText(text)
517-
.then(() => {
518-
if (copiedTimerRef.current != null) {
519-
clearTimeout(copiedTimerRef.current);
520-
}
521-
setCopied(true);
522-
copiedTimerRef.current = setTimeout(() => {
523-
setCopied(false);
524-
copiedTimerRef.current = null;
525-
}, 1200);
526-
})
527-
.catch((cause) => {
528-
reportMarkdownActionFailure({ operation: "copy-table", format }, cause);
529-
});
530-
};
531-
532-
useEffect(
533-
() => () => {
534-
if (copiedTimerRef.current != null) {
535-
clearTimeout(copiedTimerRef.current);
536-
copiedTimerRef.current = null;
537-
}
538-
},
539-
[],
540-
);
541-
542-
return (
543-
<div
544-
ref={containerRef}
545-
className="chat-markdown-table-container"
546-
data-expanded={expanded ? "true" : "false"}
547-
>
548-
<ScrollArea radius="none" chainVerticalScroll scrollFade className="w-full max-w-full">
549-
<table ref={tableRef} {...props}>
550-
{children}
551-
</table>
552-
</ScrollArea>
553-
<div className="mt-0.5 flex items-center justify-between select-none">
554-
{hasClippedCells ? (
555-
<Tooltip>
556-
<TooltipTrigger
557-
render={
558-
<Button
559-
type="button"
560-
variant={expanded ? "secondary" : "ghost-muted"}
561-
size="icon-xs"
562-
aria-pressed={expanded}
563-
onClick={() => setExpanded((value) => !value)}
564-
aria-label={expandLabel}
565-
/>
566-
}
567-
>
568-
<MorphIcon className="size-3" icon={expanded ? Minimize2 : Maximize2} />
569-
</TooltipTrigger>
570-
<TooltipPopup side="top">{expandLabel}</TooltipPopup>
571-
</Tooltip>
572-
) : (
573-
<span />
574-
)}
575-
<Menu>
576-
<Tooltip>
577-
<TooltipTrigger
578-
render={
579-
<MenuTrigger
580-
render={
581-
<Button
582-
type="button"
583-
variant="ghost-muted"
584-
size="icon-xs"
585-
aria-label={copyLabel}
586-
/>
587-
}
588-
/>
589-
}
590-
>
591-
<MorphIcon className="size-3" icon={copied ? Check : Copy} />
592-
</TooltipTrigger>
593-
<TooltipPopup side="top">{copyLabel}</TooltipPopup>
594-
</Tooltip>
595-
<MenuPopup align="end">
596-
<MenuItem onClick={() => handleCopy("markdown")}>Copy as Markdown</MenuItem>
597-
<MenuItem onClick={() => handleCopy("csv")}>Copy as CSV</MenuItem>
598-
</MenuPopup>
599-
</Menu>
600-
</div>
601-
</div>
602-
);
603-
}
604-
605-
function MarkdownDetails({
606-
children,
607-
open = false,
608-
}: Pick<React.ComponentProps<"details">, "children" | "open">) {
609-
const [isOpen, setIsOpen] = useState(open);
610-
const searching = use(MarkdownFindContext);
611-
const expanded = isOpen;
612-
const revealForFind = () => setIsOpen(true);
613-
// Base UI only listens for `beforematch` on a panel mounted at its first
614-
// render; a closed panel mounts later, when find starts, so listen here.
615-
const revealRef = findRevealRef(revealForFind);
616-
const childNodes = Children.toArray(children);
617-
const summaryIndex = childNodes.findIndex(
618-
(child) => isValidElement(child) && child.type === "summary",
619-
);
620-
const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null;
621-
const summary =
622-
isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children
623-
? summaryNode.props.children
624-
: "Details";
625-
const content = childNodes.filter((_, index) => index !== summaryIndex);
626-
627-
return (
628-
<div className="my-2 border-y border-border/60">
629-
<Collapsible
630-
open={expanded}
631-
onOpenChange={setIsOpen}
632-
data-markdown-details=""
633-
data-markdown-details-open={expanded ? "true" : "false"}
634-
>
635-
<CollapsibleTrigger
636-
className="flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90"
637-
data-markdown-details-summary=""
638-
>
639-
<ChevronRightIcon
640-
className="size-4 shrink-0 text-muted-foreground transition-transform"
641-
aria-hidden
642-
/>
643-
<span>{summary}</span>
644-
</CollapsibleTrigger>
645-
<CollapsiblePanel ref={revealRef} hiddenUntilFound={searching}>
646-
<div
647-
className="pb-3 ps-6 text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)]"
648-
data-markdown-details-content=""
649-
>
650-
{content}
651-
</div>
652-
</CollapsiblePanel>
653-
</Collapsible>
654-
</div>
655-
);
656-
}
657-
658442
interface MarkdownFileLinkProps {
659443
environmentId: EnvironmentId | null;
660444
href: string;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { ChevronRightIcon } from "lucide-react";
2+
import {
3+
Children,
4+
isValidElement,
5+
use,
6+
useState,
7+
type ComponentProps,
8+
type ReactNode,
9+
} from "react";
10+
11+
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
12+
import { findRevealRef, MarkdownFindContext } from "./markdownFindContext";
13+
14+
export function MarkdownDetails({
15+
children,
16+
open = false,
17+
}: Pick<ComponentProps<"details">, "children" | "open">) {
18+
const [isOpen, setIsOpen] = useState(open);
19+
const searching = use(MarkdownFindContext);
20+
const expanded = isOpen;
21+
const revealForFind = () => setIsOpen(true);
22+
// Base UI only listens for `beforematch` on a panel mounted at its first
23+
// render; a closed panel mounts later, when find starts, so listen here.
24+
const revealRef = findRevealRef(revealForFind);
25+
const childNodes = Children.toArray(children);
26+
const summaryIndex = childNodes.findIndex(
27+
(child) => isValidElement(child) && child.type === "summary",
28+
);
29+
const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null;
30+
const summary =
31+
isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children
32+
? summaryNode.props.children
33+
: "Details";
34+
const content = childNodes.filter((_, index) => index !== summaryIndex);
35+
36+
return (
37+
<div className="my-2 border-y border-border/60">
38+
<Collapsible
39+
open={expanded}
40+
onOpenChange={setIsOpen}
41+
data-markdown-details=""
42+
data-markdown-details-open={expanded ? "true" : "false"}
43+
>
44+
<CollapsibleTrigger
45+
className="flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90"
46+
data-markdown-details-summary=""
47+
>
48+
<ChevronRightIcon
49+
className="size-4 shrink-0 text-muted-foreground transition-transform"
50+
aria-hidden
51+
/>
52+
<span>{summary}</span>
53+
</CollapsibleTrigger>
54+
<CollapsiblePanel ref={revealRef} hiddenUntilFound={searching}>
55+
<div
56+
className="pb-3 ps-6 text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)]"
57+
data-markdown-details-content=""
58+
>
59+
{content}
60+
</div>
61+
</CollapsiblePanel>
62+
</Collapsible>
63+
</div>
64+
);
65+
}

0 commit comments

Comments
 (0)