From 8d0ed76e3acb0f15d886e625fc82a9a145027769 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 10 Apr 2026 11:20:13 +0300 Subject: [PATCH 1/5] feat(diff-reply): support selection replies from diff panel - Add DiffSelectionReplyToolbar component with selection detection and floating toolbar - Extend QuotedContext type with optional filePath field for diff-sourced selections - Differentiate diff-sourced quotes (emerald) from text quotes (violet) in composer UI - Replace shortenPath with relativizePath for workspace-relative file path display - Track session running state in work log derivation for accurate task status - Improve diff line rendering with better color contrast and separator styling - Fix sidebar padding in fullscreen mode --- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/DiffPanel.tsx | 16 + .../components/DiffSelectionReplyToolbar.tsx | 302 ++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 2 +- .../src/components/chat/FileChangeCard.tsx | 17 +- .../src/components/chat/InlineDiffPreview.tsx | 27 +- .../src/components/chat/MessagesTimeline.tsx | 4 +- .../chat/QuotedContextInlineChip.tsx | 12 +- .../components/chat/SelectionReplyToolbar.tsx | 18 +- apps/web/src/lib/quotedContext.ts | 21 +- apps/web/src/session-logic.test.ts | 4 +- apps/web/src/session-logic.ts | 16 +- apps/web/test/wsRpcHarness.ts | 2 + 13 files changed, 407 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/DiffSelectionReplyToolbar.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 253063691b1..224a4faa5ca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1215,8 +1215,9 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveWorkLogEntries(timelineThreadActivities, timelineLatestTurn?.turnId ?? undefined, { excludeTodoToolCalls: showTodosInComposer, + isSessionRunning: phase === "running", }), - [timelineLatestTurn?.turnId, timelineThreadActivities, showTodosInComposer], + [timelineLatestTurn?.turnId, timelineThreadActivities, showTodosInComposer, phase], ); const timelineLatestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(timelineThreadActivities, timelineLatestTurn?.turnId), @@ -4835,6 +4836,7 @@ export default function ChatView({ threadId }: ChatViewProps) { key={ctx.id} preview={formatQuotedContextPreview(ctx)} tooltipText={formatQuotedContextTooltip(ctx)} + isDiff={Boolean(ctx.filePath)} onRemove={() => removeComposerDraftQuotedContext(threadId, ctx.id)} /> ))} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index cfb0c3073e7..8d10e64f449 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -22,6 +22,9 @@ import { } from "react"; import { openInPreferredEditor } from "../editorPreferences"; import { useGitStatus } from "~/lib/gitStatusState"; +import { useComposerDraftStore } from "../composerDraftStore"; +import type { QuotedContext } from "../lib/quotedContext"; +import { DiffSelectionReplyToolbar } from "./DiffSelectionReplyToolbar"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -197,6 +200,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitStatusQuery = useGitStatus(activeCwd ?? null); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const addQuotedContext = useComposerDraftStore((store) => store.addQuotedContext); + const onDiffReplyToSelection = useCallback( + (context: QuotedContext) => { + if (!activeThreadId) return; + addQuotedContext(activeThreadId, context); + }, + [activeThreadId, addQuotedContext], + ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const orderedTurnDiffSummaries = useMemo( @@ -732,6 +743,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { )} + )} diff --git a/apps/web/src/components/DiffSelectionReplyToolbar.tsx b/apps/web/src/components/DiffSelectionReplyToolbar.tsx new file mode 100644 index 00000000000..4d936809193 --- /dev/null +++ b/apps/web/src/components/DiffSelectionReplyToolbar.tsx @@ -0,0 +1,302 @@ +import { CheckIcon, CopyIcon, ReplyIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { MessageId, type TurnId } from "@marcode/contracts"; +import type { QuotedContext } from "../lib/quotedContext"; +import { truncateQuotedText } from "../lib/quotedContext"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { randomUUID } from "../lib/utils"; + +interface DiffSelectionReplyToolbarProps { + turnId: TurnId | null; + viewportRef: React.RefObject; + onReply: (context: QuotedContext) => void; +} + +interface ToolbarPosition { + top: number; + left: number; +} + +const TOOLBAR_HEIGHT_PX = 32; +const TOOLBAR_GAP_PX = 6; + +const DIFF_SELECTION_SYNTHETIC_MESSAGE_ID = MessageId.makeUnsafe("diff-selection"); + +function collectShadowRoots(container: HTMLElement): ShadowRoot[] { + const roots: ShadowRoot[] = []; + const walk = (el: Element) => { + if (el.shadowRoot) roots.push(el.shadowRoot); + for (const child of el.children) walk(child); + }; + walk(container); + return roots; +} + +function escapeToLightDom(node: Node): Node { + let current: Node = node; + let root = current.getRootNode(); + while (root instanceof ShadowRoot) { + current = root.host; + root = current.getRootNode(); + } + return current; +} + +function findDiffFilePath(node: Node): string | null { + let current: Element | null = node instanceof Element ? node : node.parentElement; + + const lightNode = escapeToLightDom(node); + if (lightNode instanceof Element) { + current = lightNode; + } + + while (current) { + const filePath = current.getAttribute("data-diff-file-path"); + if (filePath) return filePath; + current = current.parentElement; + } + return null; +} + +function inferLanguageFromFilePath(filePath: string): string | undefined { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return undefined; + + const extMap: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + py: "python", + rb: "ruby", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + swift: "swift", + css: "css", + scss: "scss", + html: "html", + vue: "vue", + svelte: "svelte", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + md: "markdown", + sql: "sql", + sh: "bash", + bash: "bash", + zsh: "zsh", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + cs: "csharp", + php: "php", + lua: "lua", + zig: "zig", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hs: "haskell", + ml: "ocaml", + graphql: "graphql", + gql: "graphql", + proto: "protobuf", + dart: "dart", + r: "r", + scala: "scala", + tf: "terraform", + dockerfile: "dockerfile", + }; + + return extMap[ext]; +} + +function getSelectedTextInViewport(viewportEl: HTMLElement): { + text: string; + anchorNode: Node | null; +} | null { + const selection = document.getSelection(); + if (!selection) return null; + + const text = selection.toString().trim(); + if (text.length === 0) return null; + + const shadowRoots = collectShadowRoots(viewportEl); + + let anchorNode: Node | null = null; + if ("getComposedRanges" in selection && typeof selection.getComposedRanges === "function") { + const composedRanges = ( + selection as Selection & { + getComposedRanges: (...roots: ShadowRoot[]) => StaticRange[]; + } + ).getComposedRanges(...shadowRoots); + if (composedRanges.length > 0) { + anchorNode = composedRanges[0]!.startContainer; + } + } + + if (!anchorNode && !selection.isCollapsed && selection.rangeCount > 0) { + anchorNode = selection.anchorNode; + } + + return { text, anchorNode }; +} + +export const DiffSelectionReplyToolbar = memo(function DiffSelectionReplyToolbar( + props: DiffSelectionReplyToolbarProps, +) { + const { turnId, viewportRef, onReply } = props; + const [position, setPosition] = useState(null); + const toolbarRef = useRef(null); + const lastMouseRef = useRef<{ x: number; y: number } | null>(null); + const { copyToClipboard, isCopied } = useCopyToClipboard(); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const onMouseMove = (e: MouseEvent) => { + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + }; + + const checkSelection = () => { + const snap = getSelectedTextInViewport(viewport); + const mouse = lastMouseRef.current; + if (!snap || !mouse) { + setPosition(null); + return; + } + const viewportRect = viewport.getBoundingClientRect(); + if ( + mouse.x < viewportRect.left || + mouse.x > viewportRect.right || + mouse.y < viewportRect.top || + mouse.y > viewportRect.bottom + ) { + setPosition(null); + return; + } + setPosition({ + top: mouse.y - TOOLBAR_HEIGHT_PX - TOOLBAR_GAP_PX, + left: mouse.x, + }); + }; + + const onMouseUp = () => { + requestAnimationFrame(checkSelection); + }; + + const onSelectionChange = () => { + const selection = document.getSelection(); + const text = selection?.toString().trim(); + if (!text || text.length === 0) { + setPosition(null); + } + }; + + document.addEventListener("selectionchange", onSelectionChange); + viewport.addEventListener("mousemove", onMouseMove); + viewport.addEventListener("mouseup", onMouseUp); + return () => { + document.removeEventListener("selectionchange", onSelectionChange); + viewport.removeEventListener("mousemove", onMouseMove); + viewport.removeEventListener("mouseup", onMouseUp); + }; + }, [viewportRef]); + + const handleReply = useCallback(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const snap = getSelectedTextInViewport(viewport); + if (!snap) return; + + const { text: rawText, wasTruncated } = truncateQuotedText(snap.text); + if (wasTruncated) { + console.warn("Quoted diff text was truncated to 5000 characters"); + } + + const filePath = snap.anchorNode ? findDiffFilePath(snap.anchorNode) : null; + const codeLanguage = filePath ? inferLanguageFromFilePath(filePath) : undefined; + + const context: QuotedContext = { + id: randomUUID(), + messageId: DIFF_SELECTION_SYNTHETIC_MESSAGE_ID, + turnId, + text: rawText, + codeLanguage, + filePath: filePath ?? undefined, + }; + + onReply(context); + window.getSelection()?.removeAllRanges(); + setPosition(null); + }, [viewportRef, turnId, onReply]); + + const handleCopy = useCallback(() => { + const selection = window.getSelection(); + if (!selection) return; + const text = selection.toString().trim(); + if (text.length > 0) { + copyToClipboard(text); + } + }, [copyToClipboard]); + + useEffect(() => { + if (!position) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + window.getSelection()?.removeAllRanges(); + setPosition(null); + return; + } + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "r") { + e.preventDefault(); + handleReply(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [position, handleReply]); + + if (!position) return null; + + return createPortal( +
e.preventDefault()} + > + + +
, + document.body, + ); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4a190ab08ba..d8c85e63604 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2125,7 +2125,7 @@ export default function Sidebar() { {isElectron ? ( {wordmark} diff --git a/apps/web/src/components/chat/FileChangeCard.tsx b/apps/web/src/components/chat/FileChangeCard.tsx index 87f786ea3ad..23ac022069b 100644 --- a/apps/web/src/components/chat/FileChangeCard.tsx +++ b/apps/web/src/components/chat/FileChangeCard.tsx @@ -5,7 +5,7 @@ import { DiffLinesBlock, DiffStatSummary, OPERATION_LABELS, - shortenPath, + relativizePath, } from "./InlineDiffPreview"; const PREVIEW_MAX_HEIGHT = "120px"; @@ -13,15 +13,16 @@ const MIN_OVERFLOW_PX = 24; interface FileChangeCardProps { diffPreviews: ReadonlyArray; + cwd: string | undefined; } -function HunkHeader(props: { hunk: InlineDiffHunk }) { - const { hunk } = props; +function HunkHeader(props: { hunk: InlineDiffHunk; cwd: string | undefined }) { + const { hunk, cwd } = props; return (
- {shortenPath(hunk.filePath)} + {relativizePath(hunk.filePath, cwd)} {OPERATION_LABELS[hunk.operation]} @@ -32,7 +33,7 @@ function HunkHeader(props: { hunk: InlineDiffHunk }) { } export const FileChangeCard = memo(function FileChangeCard(props: FileChangeCardProps) { - const { diffPreviews } = props; + const { diffPreviews, cwd } = props; const [expanded, setExpanded] = useState(false); const [previewOverflows, setPreviewOverflows] = useState(false); const previewRef = useRef(null); @@ -63,7 +64,7 @@ export const FileChangeCard = memo(function FileChangeCard(props: FileChangeCard className="overflow-hidden rounded-xl border border-border/40 border-l-2 border-l-primary/25 bg-card/25" > {isSingleHunk ? ( - + ) : (
@@ -81,7 +82,7 @@ export const FileChangeCard = memo(function FileChangeCard(props: FileChangeCard {diffPreviews.map((hunk) => (
- {shortenPath(hunk.filePath)} + {relativizePath(hunk.filePath, cwd)} - {hunk.filePath} + {relativizePath(hunk.filePath, cwd)}
)} diff --git a/apps/web/src/components/chat/InlineDiffPreview.tsx b/apps/web/src/components/chat/InlineDiffPreview.tsx index 132780b3c3e..6e7f2265963 100644 --- a/apps/web/src/components/chat/InlineDiffPreview.tsx +++ b/apps/web/src/components/chat/InlineDiffPreview.tsx @@ -113,10 +113,13 @@ function extractLineHtmls(fullHtml: string): string[] { }); } -export function shortenPath(filePath: string): string { - const parts = filePath.split("/"); - if (parts.length <= 3) return filePath; - return `.../${parts.slice(-2).join("/")}`; +export function relativizePath(filePath: string, cwd: string | undefined): string { + if (!cwd || !filePath.startsWith("/")) return filePath; + const normalizedCwd = cwd.endsWith("/") ? cwd : `${cwd}/`; + if (filePath.startsWith(normalizedCwd)) { + return filePath.slice(normalizedCwd.length); + } + return filePath; } function buildLineKeys(lines: ReadonlyArray): string[] { @@ -155,8 +158,8 @@ export function DiffStatSummary(props: { additions: number; deletions: number }) } const LINE_BG: Record = { - deletion: "bg-[color-mix(in_srgb,var(--background)_88%,var(--destructive))]", - addition: "bg-[color-mix(in_srgb,var(--background)_88%,var(--success))]", + deletion: "bg-[color-mix(in_srgb,var(--background)_92%,var(--destructive))]", + addition: "bg-[color-mix(in_srgb,var(--background)_92%,var(--success))]", context: "", separator: "", }; @@ -240,16 +243,16 @@ export const DiffLinesBlock = memo(function DiffLinesBlock(props: DiffLinesBlock return (
-
+        
           {keyedLines.map((line, idx) => {
             if (line.type === "separator") {
               const hiddenCount = parseInt(line.content, 10);
               return (
                 
- ··· {hiddenCount > 0 ? `${hiddenCount} lines hidden` : "···"} ··· + {hiddenCount > 0 ? `${hiddenCount} unmodified lines` : "···"}
); } @@ -258,12 +261,12 @@ export const DiffLinesBlock = memo(function DiffLinesBlock(props: DiffLinesBlock
- + {MARKER_CHAR[line.type]} {highlighted ? ( @@ -305,7 +308,7 @@ export const InlineDiffPreview = memo(function InlineDiffPreview(props: { hunk: > - {OPERATION_LABELS[hunk.operation]}({shortenPath(hunk.filePath)}) + {OPERATION_LABELS[hunk.operation]}({hunk.filePath}) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e532a8ccea6..cdc6069faa1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -405,7 +405,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); })()} - {row.kind === "file-change" && } + {row.kind === "file-change" && ( + + )} {row.kind === "exploration" && } diff --git a/apps/web/src/components/chat/QuotedContextInlineChip.tsx b/apps/web/src/components/chat/QuotedContextInlineChip.tsx index fb772167410..42a8d1c2646 100644 --- a/apps/web/src/components/chat/QuotedContextInlineChip.tsx +++ b/apps/web/src/components/chat/QuotedContextInlineChip.tsx @@ -1,4 +1,4 @@ -import { QuoteIcon, XIcon } from "lucide-react"; +import { DiffIcon, QuoteIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { @@ -12,11 +12,13 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; interface QuotedContextInlineChipProps { preview: string; tooltipText: string; + isDiff?: boolean; onRemove?: () => void; } export function QuotedContextInlineChip(props: QuotedContextInlineChipProps) { - const { preview, tooltipText, onRemove } = props; + const { preview, tooltipText, isDiff, onRemove } = props; + const Icon = isDiff ? DiffIcon : QuoteIcon; return ( @@ -25,10 +27,12 @@ export function QuotedContextInlineChip(props: QuotedContextInlineChipProps) { - + {preview} diff --git a/apps/web/src/components/chat/SelectionReplyToolbar.tsx b/apps/web/src/components/chat/SelectionReplyToolbar.tsx index 8c010e5c58c..ca62baaf672 100644 --- a/apps/web/src/components/chat/SelectionReplyToolbar.tsx +++ b/apps/web/src/components/chat/SelectionReplyToolbar.tsx @@ -22,6 +22,14 @@ interface ToolbarPosition { const TOOLBAR_HEIGHT_PX = 32; const TOOLBAR_GAP_PX = 6; +function clampRangeToContainer(range: Range, containerEl: HTMLElement): Range { + if (containerEl.contains(range.endContainer)) return range; + + const clamped = range.cloneRange(); + clamped.setEndAfter(containerEl.lastChild ?? containerEl); + return clamped; +} + function getSelectionMeta(containerEl: HTMLElement): { text: string; startOffset: number; @@ -35,19 +43,21 @@ function getSelectionMeta(containerEl: HTMLElement): { const range = selection.getRangeAt(0); if (!range || !containerEl.contains(range.startContainer)) return null; - const text = selection.toString().trim(); + const effective = clampRangeToContainer(range, containerEl); + + const text = effective.toString().trim(); if (text.length === 0) return null; - const codeBlock = findAncestorCodeBlock(range.startContainer, containerEl); + const codeBlock = findAncestorCodeBlock(effective.startContainer, containerEl); const codeLanguage = codeBlock ? extractCodeLanguageFromBlock(codeBlock) : undefined; const preRange = document.createRange(); preRange.selectNodeContents(containerEl); - preRange.setEnd(range.startContainer, range.startOffset); + preRange.setEnd(effective.startContainer, effective.startOffset); const startOffset = preRange.toString().length; const endOffset = startOffset + text.length; - const rect = range.getBoundingClientRect(); + const rect = effective.getBoundingClientRect(); return { text, startOffset, endOffset, codeLanguage, rect }; } diff --git a/apps/web/src/lib/quotedContext.ts b/apps/web/src/lib/quotedContext.ts index 9eeec23b8fe..7dadafeb843 100644 --- a/apps/web/src/lib/quotedContext.ts +++ b/apps/web/src/lib/quotedContext.ts @@ -8,6 +8,7 @@ export interface QuotedContext { readonly codeLanguage?: string | undefined; readonly startOffset?: number | undefined; readonly endOffset?: number | undefined; + readonly filePath?: string | undefined; } const MAX_QUOTED_TEXT_LENGTH = 5000; @@ -41,10 +42,15 @@ export function truncateQuotedText(text: string): { text: string; wasTruncated: } export function quotedContextDedupKey(context: QuotedContext): string { - return `${context.messageId}\u0000${context.startOffset ?? ""}\u0000${context.endOffset ?? ""}`; + const source = context.filePath ?? context.messageId; + return `${source}\u0000${context.startOffset ?? ""}\u0000${context.endOffset ?? ""}`; } export function formatQuotedContextPreview(context: QuotedContext): string { + if (context.filePath) { + const fileName = context.filePath.split("/").pop() ?? context.filePath; + return fileName; + } const maxPreview = 80; const singleLine = context.text.replace(/\n/g, " ").trim(); return singleLine.length > maxPreview ? `${singleLine.slice(0, maxPreview - 1)}…` : singleLine; @@ -70,7 +76,10 @@ function formatSingleQuotedContextBlock(context: QuotedContext): string { const safeLang = context.codeLanguage ? sanitizeCodeLanguage(context.codeLanguage) : undefined; const langAttr = safeLang ? ` language="${safeLang}"` : ""; const safeText = escapeQuotedContextBody(context.text); - return `\n${safeText}\n`; + const sourceAttr = context.filePath + ? ` file_path="${context.filePath}"` + : ` message_id="${context.messageId}"`; + return `\n${safeText}\n`; } export function buildQuotedContextBlock(contexts: ReadonlyArray): string { @@ -104,7 +113,13 @@ export function extractLeadingQuotedContexts(text: string): ExtractedQuotedConte const body = blockMatch[2] ?? ""; const langMatch = attrs.match(/language="([^"]+)"/); const language = langMatch?.[1]; - const header = language ? `Quoted code (${language})` : "Quoted text"; + const filePathMatch = attrs.match(/file_path="([^"]+)"/); + const filePath = filePathMatch?.[1]; + const header = filePath + ? `Quoted diff (${filePath.split("/").pop() ?? filePath})` + : language + ? `Quoted code (${language})` + : "Quoted text"; contexts.push({ header, body: body.trim() }); blockMatch = blockPattern.exec(leadingBlock); } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 1c5718ba1ad..33f2467ec1c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -684,7 +684,7 @@ describe("deriveWorkLogEntries", () => { }), ]; - const entries = deriveWorkLogEntries(activities, undefined); + const entries = deriveWorkLogEntries(activities, undefined, { isSessionRunning: true }); expect(entries).toHaveLength(1); expect(entries[0]!.agentGroup!.tasks[0]!.status).toBe("running"); expect(entries[0]!.label).toBe("1 agent running"); @@ -1076,7 +1076,7 @@ describe("deriveWorkLogEntries", () => { }), ]; - const entries = deriveWorkLogEntries(activities, undefined); + const entries = deriveWorkLogEntries(activities, undefined, { isSessionRunning: true }); expect(entries[0]!.label).toBe("2 parallel agents (1 running)"); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 6ec7563bfb9..d031a7eca9f 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -547,9 +547,10 @@ export function deriveTodoItems( export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, - options?: { excludeTodoToolCalls?: boolean }, + options?: { excludeTodoToolCalls?: boolean; isSessionRunning?: boolean }, ): WorkLogEntry[] { const excludeTodos = options?.excludeTodoToolCalls === true; + const isSessionRunning = options?.isSessionRunning ?? false; const ordered = [...activities].toSorted(compareActivitiesByOrder); const collabToolDataByItemId = new Map(); @@ -667,6 +668,7 @@ export function deriveWorkLogEntries( taskGroups, collabToolDataByItemId, collabToolDataUnkeyed, + isSessionRunning, ); if (groupEntry) entries.push(groupEntry); } @@ -812,6 +814,7 @@ function buildAgentTaskSummary( group: TaskActivityGroup, collabToolDataByItemId: ReadonlyMap, collabToolDataUnkeyed: ReadonlyArray, + isSessionRunning: boolean, ): AgentTaskSummary { const completedPayload = asRecord(group.completed?.payload); const latestProgress = group.progressEntries.at(-1); @@ -835,6 +838,8 @@ function buildAgentTaskSummary( if (rawStatus === "failed") status = "failed"; else if (rawStatus === "stopped") status = "stopped"; else status = "completed"; + } else if (!isSessionRunning) { + status = "completed"; } const usageSource = completedPayload ?? latestProgressPayload; @@ -936,11 +941,18 @@ function buildAgentGroupEntry( taskGroups: Map, collabToolDataByItemId: ReadonlyMap, collabToolDataUnkeyed: ReadonlyArray, + isSessionRunning: boolean, ): DerivedWorkLogEntry | null { if (taskGroups.size === 0) return null; const tasks = [...taskGroups.entries()].map(([taskId, group]) => - buildAgentTaskSummary(taskId, group, collabToolDataByItemId, collabToolDataUnkeyed), + buildAgentTaskSummary( + taskId, + group, + collabToolDataByItemId, + collabToolDataUnkeyed, + isSessionRunning, + ), ); const hasFailed = tasks.some((t) => t.status === "failed"); diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index 8f3fead5327..997055299d1 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -29,6 +29,8 @@ const STREAM_METHODS = new Set([ WS_METHODS.subscribeTerminalEvents, WS_METHODS.subscribeServerConfig, WS_METHODS.subscribeServerLifecycle, + WS_METHODS.subscribeCommandOutput, + WS_METHODS.subscribeJiraConnectionStatus, ]); const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys()); From 5876e166a3b6f5923b38ae4c788937e850621bf8 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 10 Apr 2026 12:46:27 +0300 Subject: [PATCH 2/5] feat(chat): improve selection detection and quoted replies - Refactor SelectionReplyToolbar for efficient container registry with selection callbacks - Add global selection handler for smoother selection state updates - Adjust user message height estimation to account for quoted contexts - Improve toolbar positioning logic with state ref synchronization - Add overflow-anchor CSS fix for smoother scrolling in ChatView --- apps/web/src/components/ChatView.tsx | 2 +- .../src/components/chat/MessagesTimeline.tsx | 1 - .../components/chat/SelectionReplyToolbar.tsx | 73 ++++++++++++++++--- apps/web/src/components/timelineHeight.ts | 17 ++++- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 224a4faa5ca..b5bb1888b90 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4616,7 +4616,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Messages */}
{ diff --git a/apps/web/src/components/chat/SelectionReplyToolbar.tsx b/apps/web/src/components/chat/SelectionReplyToolbar.tsx index ca62baaf672..8c7c822e33e 100644 --- a/apps/web/src/components/chat/SelectionReplyToolbar.tsx +++ b/apps/web/src/components/chat/SelectionReplyToolbar.tsx @@ -22,6 +22,50 @@ interface ToolbarPosition { const TOOLBAR_HEIGHT_PX = 32; const TOOLBAR_GAP_PX = 6; +type SelectionContainerCallback = (hasSelection: boolean) => void; +const containerRegistry = new Map(); +let globalListenerAttached = false; + +function findRegisteredContainer(node: Node | null): HTMLElement | null { + let current: Node | null = node; + while (current) { + if (current instanceof HTMLElement && containerRegistry.has(current)) { + return current; + } + current = current.parentNode; + } + return null; +} + +function handleGlobalSelectionChange() { + const selection = window.getSelection(); + let matchedContainer: HTMLElement | null = null; + + if (selection && !selection.isCollapsed && selection.rangeCount > 0 && selection.anchorNode) { + matchedContainer = findRegisteredContainer(selection.anchorNode); + } + + for (const [container, callback] of containerRegistry) { + callback(container === matchedContainer); + } +} + +function registerSelectionContainer(el: HTMLElement, callback: SelectionContainerCallback) { + containerRegistry.set(el, callback); + if (!globalListenerAttached) { + globalListenerAttached = true; + document.addEventListener("selectionchange", handleGlobalSelectionChange); + } +} + +function unregisterSelectionContainer(el: HTMLElement) { + containerRegistry.delete(el); + if (containerRegistry.size === 0 && globalListenerAttached) { + globalListenerAttached = false; + document.removeEventListener("selectionchange", handleGlobalSelectionChange); + } +} + function clampRangeToContainer(range: Range, containerEl: HTMLElement): Range { if (containerEl.contains(range.endContainer)) return range; @@ -85,31 +129,42 @@ export const SelectionReplyToolbar = memo(function SelectionReplyToolbar( ) { const { messageId, turnId, containerRef, onReply } = props; const [position, setPosition] = useState(null); + const positionRef = useRef(null); const toolbarRef = useRef(null); const { copyToClipboard, isCopied } = useCopyToClipboard(); useEffect(() => { - const handleSelectionChange = () => { - const container = containerRef.current; - if (!container) { - setPosition(null); + const container = containerRef.current; + if (!container) return; + + const callback: SelectionContainerCallback = (hasSelection) => { + if (!hasSelection) { + if (positionRef.current !== null) { + positionRef.current = null; + setPosition(null); + } return; } const meta = getSelectionMeta(container); if (!meta) { - setPosition(null); + if (positionRef.current !== null) { + positionRef.current = null; + setPosition(null); + } return; } - setPosition({ + const next = { top: meta.rect.top - TOOLBAR_HEIGHT_PX - TOOLBAR_GAP_PX, left: meta.rect.left + meta.rect.width / 2, - }); + }; + positionRef.current = next; + setPosition(next); }; - document.addEventListener("selectionchange", handleSelectionChange); - return () => document.removeEventListener("selectionchange", handleSelectionChange); + registerSelectionContainer(container, callback); + return () => unregisterSelectionContainer(container); }, [containerRef]); const handleReply = useCallback(() => { diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index d0b4814184c..e1918f3758e 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,6 +1,9 @@ +import { extractLeadingQuotedContexts } from "../lib/quotedContext"; import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; +const USER_QUOTED_CONTEXT_LABEL_HEIGHT_PX = 30; + const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; const USER_CHARS_PER_LINE_FALLBACK = 56; const USER_LINE_HEIGHT_PX = 22.75; @@ -83,7 +86,10 @@ export function estimateTimelineMessageHeight( if (message.role === "user") { const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); - const displayedUserMessage = deriveDisplayedUserMessageState(message.text); + const quotedExtracted = extractLeadingQuotedContexts(message.text); + const textAfterQuoted = + quotedExtracted.contextCount > 0 ? quotedExtracted.promptText : message.text; + const displayedUserMessage = deriveDisplayedUserMessageState(textAfterQuoted); const renderedText = displayedUserMessage.contexts.length > 0 ? [ @@ -97,7 +103,14 @@ export function estimateTimelineMessageHeight( const attachmentCount = message.attachments?.length ?? 0; const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; - return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight; + const quotedContextHeight = + quotedExtracted.contextCount > 0 ? USER_QUOTED_CONTEXT_LABEL_HEIGHT_PX : 0; + return ( + USER_BASE_HEIGHT_PX + + estimatedLines * USER_LINE_HEIGHT_PX + + attachmentHeight + + quotedContextHeight + ); } // `system` messages are not rendered in the chat timeline, but keep a stable From 6f9271c16d0158b98379333cfa2fb6c8b77c755c Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 10 Apr 2026 13:02:57 +0300 Subject: [PATCH 3/5] refactor(timeline): replace virtualizer with CSS content-visibility: aut Remove @tanstack/react-virtual from MessagesTimeline in favor of native CSS containment. Variable-height messages (markdown, diffs, quoted contexts), async content (Suspense highlighting), and expandable sections made height estimation unreliable. CSS content-visibility: auto skips painting offscreen content natively without positioning bugs. Simplify row rendering: all rows render in normal document flow with containIntrinsicBlockSize hints. Remove virtualizer snapshot telemetry. --- AGENTS.md | 16 +- .../src/components/chat/MessagesTimeline.tsx | 208 +++--------------- 2 files changed, 49 insertions(+), 175 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 537bdaea55c..5240d0eb518 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ ## Rebrand Note -This project was forked from T3 Code and fully rebranded to MarCode. When merging upstream changes, always check for and replace any remaining T3 references: +This project was forked from T3 Code and fully rebranded to MarCode. When merging upstream changes, always check for and replace any remaining T3 references, and **reject reintroduction of JS virtualization in `MessagesTimeline.tsx`** (see "Timeline rendering" section under Performance): - Package imports: `@marcode/contracts`, `@marcode/shared/*` (never `@t3tools`) - Env vars: `MARCODE_` prefix (never `T3CODE_`) @@ -103,6 +103,20 @@ ChatView uses **fine-grained Zustand selectors** (one per thread/project ID) ins - Its volatile dependencies (`activePendingProgress`, `activePendingUserInput`, `composerTerminalContexts`, `composerJiraTaskContexts`) are accessed via **refs** in callbacks, not in the `useCallback` dependency array. - Fallback empty arrays use **module-level constants** (`EMPTY_TERMINAL_CONTEXT_DRAFTS`, `EMPTY_JIRA_TASK_DRAFTS`) instead of inline `[]`. +### Timeline rendering: NO JS virtualization (`MessagesTimeline.tsx`) + +**CRITICAL — DO NOT REINTRODUCE `@tanstack/react-virtual` or any JS virtualizer for the messages timeline.** This has been deliberately removed twice. Upstream (T3 Code) uses `useVirtualizer` with absolute positioning + `transform: translateY()`, but it causes persistent message overlap and scroll lag in MarCode because: + +- Variable-height messages (markdown, code blocks, images, expandable diffs, quoted contexts) make height estimation fundamentally inaccurate +- Async content (Suspense code highlighting, image loads) changes height after initial measurement +- Expandable/collapsible sections (Show full diff, work groups) change height without virtualizer notification +- `ChatView.tsx` directly manipulates `scrollTop` for interaction anchoring and auto-scroll, which desynchronizes from the virtualizer's internal scroll state +- `SelectionReplyToolbar` wraps every assistant message in extra DOM, adding unmeasured height + +**Instead, we use CSS `content-visibility: auto`** with `contain-intrinsic-block-size` hints. All rows render in normal document flow — overlap is physically impossible. The browser natively skips painting offscreen content, giving equivalent performance without the positioning bugs. Height estimates in `timelineHeight.ts` feed into `containIntrinsicBlockSize` for accurate scrollbar sizing. + +When merging upstream changes that touch `MessagesTimeline.tsx`, **reject any reintroduction of `useVirtualizer`, `measureElement`, `VirtualItem`, absolute-positioned row containers, or `shouldAdjustScrollPositionOnItemSizeChange`**. Keep the `content-visibility: auto` rendering path. + ### Timeline row memoization (`MessagesTimeline.tsx`) Each timeline row renders through a `memo`'d `TimelineRowContent` component (not an inline function). When adding new row types or modifying row rendering, keep the logic inside `TimelineRowContent` to preserve per-row memoization. diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6ed1ecdeb80..c7f4bd12155 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -9,13 +9,10 @@ import { useState, type ReactNode, } from "react"; -import { - measureElement as measureVirtualElement, - type VirtualItem, - useVirtualizer, -} from "@tanstack/react-virtual"; +// Virtualization replaced with CSS content-visibility: auto for overlap-free +// rendering. See commit 8d4da730 for the original removal rationale. import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; +// AUTO_SCROLL_BOTTOM_THRESHOLD_PX no longer needed without virtualizer import { type ChatMessage, type TurnDiffSummary } from "../../types"; import { type ComposerImageAttachment } from "../../composerDraftStore"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; @@ -40,7 +37,7 @@ import { } from "lucide-react"; import { Button } from "../ui/button"; import { Textarea } from "../ui/textarea"; -import { clamp } from "effect/Number"; +import { estimateTimelineMessageHeight } from "../timelineHeight"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; @@ -49,7 +46,6 @@ import { MessageCopyButton } from "./MessageCopyButton"; import { MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, - estimateMessagesTimelineRowHeight, normalizeCompactToolLabel, type MessagesTimelineRow, } from "./MessagesTimeline.logic"; @@ -70,11 +66,7 @@ import { cn } from "~/lib/utils"; import { extractTrailingJiraContexts, type ParsedJiraContextEntry } from "~/lib/jiraContext"; import { JiraTaskInlineChip } from "./JiraTaskInlineChip"; import { SelectionReplyToolbar } from "./SelectionReplyToolbar"; -import { - extractLeadingQuotedContexts, - type ParsedQuotedContextEntry, - type QuotedContext, -} from "~/lib/quotedContext"; +import { extractLeadingQuotedContexts, type QuotedContext } from "~/lib/quotedContext"; import { UserMessageQuotedContextLabel } from "./UserMessageQuotedContextLabel"; import { type TimestampFormat } from "@marcode/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; @@ -84,7 +76,6 @@ import { textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; -const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; const EMPTY_EDIT_IMAGES: ComposerImageAttachment[] = []; interface MessagesTimelineProps { @@ -140,9 +131,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ threadId, hasMessages, isWorking, - activeTurnInProgress, + activeTurnInProgress: _activeTurnInProgress, activeTurnStartedAt, - scrollContainer, + scrollContainer: _scrollContainer, timelineEntries, completionDividerBeforeEntryId, completionSummary, @@ -172,7 +163,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onCancelEditUserMessage, onSubmitEditUserMessage, onReplyToSelection, - onVirtualizerSnapshot, + onVirtualizerSnapshot: _onVirtualizerSnapshot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); @@ -213,137 +204,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], ); - const firstUnvirtualizedRowIndex = useMemo(() => { - const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); - if (!activeTurnInProgress) return firstTailRowIndex; - - const turnStartedAtMs = - typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; - let firstCurrentTurnRowIndex = -1; - if (!Number.isNaN(turnStartedAtMs)) { - firstCurrentTurnRowIndex = rows.findIndex((row) => { - if (row.kind === "working") return true; - if (!row.createdAt) return false; - const rowCreatedAtMs = Date.parse(row.createdAt); - return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; - }); - } - - if (firstCurrentTurnRowIndex < 0) { - firstCurrentTurnRowIndex = rows.findIndex( - (row) => row.kind === "message" && row.message.streaming, - ); - } - - if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; - - for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { - const previousRow = rows[index]; - if (!previousRow || previousRow.kind !== "message") continue; - if (previousRow.message.role === "user") { - return Math.min(index, firstTailRowIndex); - } - if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { - break; - } - } - - return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); - }, [activeTurnInProgress, activeTurnStartedAt, rows]); - - const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { - minimum: 0, - maximum: rows.length, - }); - const virtualMeasurementScopeKey = - timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; - - const rowVirtualizer = useVirtualizer({ - count: virtualizedRowCount, - getScrollElement: () => scrollContainer, - // Scope cached row measurements to the current timeline width so offscreen - // rows do not keep stale heights after wrapping changes. - getItemKey: (index: number) => { - const rowId = rows[index]?.id ?? String(index); - return `${virtualMeasurementScopeKey}:${rowId}`; - }, - estimateSize: (index: number) => { - const row = rows[index]; - if (!row) return 96; - return estimateMessagesTimelineRowHeight(row, { - expandedWorkGroups, - timelineWidthPx, - turnDiffSummaryByAssistantMessageId, - }); - }, - measureElement: measureVirtualElement, - overscan: 8, - }); - useEffect(() => { - if (timelineWidthPx === null) return; - rowVirtualizer.measure(); - }, [rowVirtualizer, timelineWidthPx]); - useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { - const viewportHeight = instance.scrollRect?.height ?? 0; - const scrollOffset = instance.scrollOffset ?? 0; - const itemIntersectsViewport = - item.end > scrollOffset && item.start < scrollOffset + viewportHeight; - if (itemIntersectsViewport) { - return false; - } - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; - }; - return () => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; - }; - }, [rowVirtualizer]); - const pendingMeasureFrameRef = useRef(null); - const onTimelineImageLoad = useCallback(() => { - if (pendingMeasureFrameRef.current !== null) return; - pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { - pendingMeasureFrameRef.current = null; - rowVirtualizer.measure(); - }); - }, [rowVirtualizer]); - useEffect(() => { - return () => { - const frame = pendingMeasureFrameRef.current; - if (frame !== null) { - window.cancelAnimationFrame(frame); - } - }; - }, []); - useLayoutEffect(() => { - if (!onVirtualizerSnapshot) { - return; - } - onVirtualizerSnapshot({ - totalSize: rowVirtualizer.getTotalSize(), - measurements: rowVirtualizer.measurementsCache - .slice(0, virtualizedRowCount) - .flatMap((measurement) => { - const row = rows[measurement.index]; - if (!row) { - return []; - } - return [ - { - id: row.id, - kind: row.kind, - index: measurement.index, - size: measurement.size, - start: measurement.start, - end: measurement.end, - }, - ]; - }), - }); - }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); - - const virtualRows = rowVirtualizer.getVirtualItems(); - const nonVirtualizedRows = rows.slice(virtualizedRowCount); + const showInlineDiffs = expandedWorkGroups; + const onTimelineImageLoad = useCallback(() => {}, []); const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< Record >({}); @@ -607,33 +469,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ data-timeline-root="true" className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" > - {virtualizedRowCount > 0 && ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; - - return ( -
- {renderRowContent(row)} -
- ); - })} + {rows.map((row) => ( +
+ {renderRowContent(row)}
- )} - - {nonVirtualizedRows.map((row) => ( -
{renderRowContent(row)}
))}
); @@ -644,6 +489,21 @@ type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; +function estimateRowHeight( + row: TimelineRow, + _showInlineDiffs: Record, + timelineWidthPx: number | null, +): number { + if (row.kind === "message") { + return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); + } + if (row.kind === "proposed-plan") return 200; + if (row.kind === "working") return 40; + if (row.kind === "file-change") return 64; + if (row.kind === "work") return 64; + return 64; +} + function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); const endedAtMs = Date.parse(endIso); From 0026e4154d815036a53b6cd9aeabe1bbb4aba368 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 10 Apr 2026 13:12:38 +0300 Subject: [PATCH 4/5] test(timeline): remove virtualization browser tests MessagesTimeline was refactored to use CSS content-visibility: auto instead of JavaScript virtualization. The virtualization browser test harness (estimateMessagesTimelineRowHeight, height measurements, virtualizer snapshots) is no longer applicable. --- ...essagesTimeline.virtualization.browser.tsx | 1051 ----------------- 1 file changed, 1051 deletions(-) delete mode 100644 apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx deleted file mode 100644 index c43f202327e..00000000000 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ /dev/null @@ -1,1051 +0,0 @@ -import "../../index.css"; - -import { MessageId, type TurnId } from "@marcode/contracts"; -import { page } from "vitest/browser"; -import { useCallback, useState, type ComponentProps } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-react"; - -import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic"; -import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; -import { MessagesTimeline } from "./MessagesTimeline"; -import { - deriveMessagesTimelineRows, - estimateMessagesTimelineRowHeight, -} from "./MessagesTimeline.logic"; - -const DEFAULT_VIEWPORT = { - width: 960, - height: 1_100, -}; -const MARKDOWN_CWD = "/repo/project"; - -interface RowMeasurement { - actualHeightPx: number; - estimatedHeightPx: number; - timelineWidthPx: number; - virtualizerSizePx: number; - renderedInVirtualizedRegion: boolean; -} - -interface VirtualizationScenario { - name: string; - targetRowId: string; - props: Omit, "scrollContainer">; - maxEstimateDeltaPx: number; -} - -interface VirtualizerSnapshot { - totalSize: number; - measurements: ReadonlyArray<{ - id: string; - kind: string; - index: number; - size: number; - start: number; - end: number; - }>; -} - -function MessagesTimelineBrowserHarness( - props: Omit, "scrollContainer">, -) { - const [scrollContainer, setScrollContainer] = useState(null); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>( - () => props.expandedWorkGroups, - ); - const handleToggleWorkGroup = useCallback( - (groupId: string) => { - setExpandedWorkGroups((current) => ({ - ...current, - [groupId]: !(current[groupId] ?? false), - })); - props.onToggleWorkGroup(groupId); - }, - [props], - ); - - return ( -
- -
- ); -} - -function isoAt(offsetSeconds: number): string { - return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString(); -} - -function createMessage(input: { - id: string; - role: ChatMessage["role"]; - text: string; - offsetSeconds: number; - attachments?: ChatMessage["attachments"]; -}): ChatMessage { - return { - id: MessageId.makeUnsafe(input.id), - role: input.role, - text: input.text, - ...(input.attachments ? { attachments: input.attachments } : {}), - createdAt: isoAt(input.offsetSeconds), - ...(input.role === "assistant" ? { completedAt: isoAt(input.offsetSeconds + 1) } : {}), - streaming: false, - }; -} - -function createToolWorkEntry(input: { - id: string; - offsetSeconds: number; - label?: string; - detail?: string; -}): WorkLogEntry { - return { - id: input.id, - createdAt: isoAt(input.offsetSeconds), - label: input.label ?? "exec_command completed", - ...(input.detail ? { detail: input.detail } : {}), - tone: "tool", - toolTitle: "exec_command", - }; -} - -function createPlan(input: { - id: string; - offsetSeconds: number; - planMarkdown: string; -}): ProposedPlan { - return { - id: input.id as ProposedPlan["id"], - turnId: null, - planMarkdown: input.planMarkdown, - implementedAt: null, - implementationThreadId: null, - createdAt: isoAt(input.offsetSeconds), - updatedAt: isoAt(input.offsetSeconds + 1), - }; -} - -function createBaseTimelineProps(input: { - messages?: ChatMessage[]; - proposedPlans?: ProposedPlan[]; - workEntries?: WorkLogEntry[]; - expandedWorkGroups?: Record; - completionDividerBeforeEntryId?: string | null; - turnDiffSummaryByAssistantMessageId?: Map; - onVirtualizerSnapshot?: ComponentProps["onVirtualizerSnapshot"]; -}): Omit, "scrollContainer"> { - return { - threadId: "test-thread", - hasMessages: true, - isWorking: false, - activeTurnInProgress: false, - activeTurnStartedAt: null, - timelineEntries: deriveTimelineEntries( - input.messages ?? [], - input.proposedPlans ?? [], - input.workEntries ?? [], - ), - completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null, - completionSummary: null, - turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(), - nowIso: isoAt(10_000), - expandedWorkGroups: input.expandedWorkGroups ?? {}, - onToggleWorkGroup: () => {}, - onOpenTurnDiff: () => {}, - revertTurnCountByUserMessageId: new Map(), - onRevertUserMessage: () => {}, - isRevertingCheckpoint: false, - onImageExpand: () => {}, - markdownCwd: MARKDOWN_CWD, - resolvedTheme: "light", - timestampFormat: "locale", - workspaceRoot: MARKDOWN_CWD, - isSendBusy: false, - isPreparingWorktree: false, - onSubagentSelect: () => {}, - editingUserMessageId: null, - editingUserMessageText: "", - editingUserMessageImages: [], - onStartEditUserMessage: () => {}, - onChangeEditingUserMessageText: () => {}, - onAddEditingUserMessageImages: () => {}, - onRemoveEditingUserMessageImage: () => {}, - onCancelEditUserMessage: () => {}, - onSubmitEditUserMessage: () => {}, - onReplyToSelection: () => {}, - ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}), - }; -} - -function createFillerMessages(input: { - prefix: string; - startOffsetSeconds: number; - pairCount: number; -}): ChatMessage[] { - const messages: ChatMessage[] = []; - for (let index = 0; index < input.pairCount; index += 1) { - const baseOffset = input.startOffsetSeconds + index * 4; - messages.push( - createMessage({ - id: `${input.prefix}-user-${index}`, - role: "user", - text: `filler user message ${index}`, - offsetSeconds: baseOffset, - }), - ); - messages.push( - createMessage({ - id: `${input.prefix}-assistant-${index}`, - role: "assistant", - text: `filler assistant message ${index}`, - offsetSeconds: baseOffset + 1, - }), - ); - } - return messages; -} - -function createChangedFilesSummary( - targetMessageId: MessageId, - files: TurnDiffSummary["files"], -): Map { - return new Map([ - [ - targetMessageId, - { - turnId: "turn-changed-files" as TurnId, - completedAt: isoAt(10), - assistantMessageId: targetMessageId, - files, - }, - ], - ]); -} - -function createChangedFilesScenario(input: { - name: string; - rowId: string; - files: TurnDiffSummary["files"]; - maxEstimateDeltaPx?: number; -}): VirtualizationScenario { - const beforeMessages = createFillerMessages({ - prefix: `${input.rowId}-before`, - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: `${input.rowId}-after`, - startOffsetSeconds: 40, - pairCount: 8, - }); - const changedFilesMessage = createMessage({ - id: input.rowId, - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - - return { - name: input.name, - targetRowId: changedFilesMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, changedFilesMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary( - changedFilesMessage.id, - input.files, - ), - }), - maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 72, - }; -} - -function createAssistantMessageScenario(input: { - name: string; - rowId: string; - text: string; - maxEstimateDeltaPx?: number; -}): VirtualizationScenario { - const beforeMessages = createFillerMessages({ - prefix: `${input.rowId}-before`, - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: `${input.rowId}-after`, - startOffsetSeconds: 40, - pairCount: 8, - }); - const assistantMessage = createMessage({ - id: input.rowId, - role: "assistant", - text: input.text, - offsetSeconds: 12, - }); - - return { - name: input.name, - targetRowId: assistantMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, assistantMessage, ...afterMessages], - }), - maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 16, - }; -} - -function buildStaticScenarios(): VirtualizationScenario[] { - const beforeMessages = createFillerMessages({ - prefix: "before", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after", - startOffsetSeconds: 40, - pairCount: 8, - }); - - const longUserMessage = createMessage({ - id: "target-user-long", - role: "user", - text: "x".repeat(3_200), - offsetSeconds: 12, - }); - const workEntries = Array.from({ length: 4 }, (_, index) => - createToolWorkEntry({ - id: `target-work-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ); - const moderatePlan = createPlan({ - id: "target-plan", - offsetSeconds: 12, - planMarkdown: [ - "# Stabilize virtualization", - "", - "- Gather baseline measurements", - "- Add browser harness coverage", - "- Compare estimated and rendered heights", - "- Fix the broken rows without broad refactors", - "- Re-run lint and typecheck", - ].join("\n"), - }); - return [ - { - name: "long user message", - targetRowId: longUserMessage.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, longUserMessage, ...afterMessages], - }), - maxEstimateDeltaPx: 56, - }, - { - name: "grouped work log row", - targetRowId: workEntries[0]!.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries, - }), - maxEstimateDeltaPx: 56, - }, - { - name: "expanded grouped work log row with show more enabled", - targetRowId: "target-work-expanded-0", - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries: Array.from({ length: 10 }, (_, index) => - createToolWorkEntry({ - id: `target-work-expanded-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ), - expandedWorkGroups: { - "target-work-expanded-0": true, - }, - }), - maxEstimateDeltaPx: 72, - }, - { - name: "proposed plan row", - targetRowId: moderatePlan.id, - props: createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - proposedPlans: [moderatePlan], - }), - maxEstimateDeltaPx: 96, - }, - createAssistantMessageScenario({ - name: "assistant single-paragraph row with plain prose", - rowId: "target-assistant-plain-prose", - text: [ - "The host is still expanding to content somewhere in the grid layout.", - "I'm stripping it back further to a plain block container so the test width", - "is actually the timeline width.", - ].join(" "), - }), - createAssistantMessageScenario({ - name: "assistant single-paragraph row with inline code", - rowId: "target-assistant-inline-code", - text: [ - "Typecheck found one exact-optional-property issue in the browser harness:", - "I was always passing `onVirtualizerSnapshot`, including `undefined`.", - "I'm tightening that object construction and rerunning the checks.", - ].join(" "), - maxEstimateDeltaPx: 28, - }), - createChangedFilesScenario({ - name: "assistant changed-files row with a compacted single-chain directory", - rowId: "target-assistant-changed-files-single-chain", - files: [ - { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 37, deletions: 45 }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.test.tsx", - additions: 0, - deletions: 26, - }, - ], - }), - createChangedFilesScenario({ - name: "assistant changed-files row with a branch after compaction", - rowId: "target-assistant-changed-files-branch-point", - files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/server/src/provider/Layers/CodexAdapter.ts", - additions: 27, - deletions: 8, - }, - { - path: "apps/server/src/provider/Layers/CodexAdapter.test.ts", - additions: 36, - deletions: 0, - }, - ], - }), - createChangedFilesScenario({ - name: "assistant changed-files row with mixed root and nested entries", - rowId: "target-assistant-changed-files-mixed-root", - files: [ - { path: "README.md", additions: 5, deletions: 1 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ], - }), - ]; -} - -async function nextFrame(): Promise { - await new Promise((resolve) => { - window.requestAnimationFrame(() => resolve()); - }); -} - -async function waitForLayout(): Promise { - await nextFrame(); - await nextFrame(); - await nextFrame(); -} - -async function setViewport(viewport: { width: number; height: number }): Promise { - await page.viewport(viewport.width, viewport.height); - await waitForLayout(); -} - -async function waitForProductionStyles(): Promise { - await vi.waitFor( - () => { - expect( - getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), - ).not.toBe(""); - expect(getComputedStyle(document.body).marginTop).toBe("0px"); - }, - { timeout: 4_000, interval: 16 }, - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - if (!element) { - throw new Error(errorMessage); - } - return element; -} - -async function measureTimelineRow(input: { - host: HTMLElement; - props: Omit, "scrollContainer">; - targetRowId: string; -}): Promise { - const scrollContainer = await waitForElement( - () => - input.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - - const rowSelector = `[data-timeline-row-id="${input.targetRowId}"]`; - const virtualRowSelector = `[data-virtual-row-id="${input.targetRowId}"]`; - - let timelineWidthPx = 0; - let actualHeightPx = 0; - let virtualizerSizePx = 0; - let renderedInVirtualizedRegion = false; - - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - const rowElement = input.host.querySelector(rowSelector); - const virtualRowElement = input.host.querySelector(virtualRowSelector); - const timelineRoot = input.host.querySelector('[data-timeline-root="true"]'); - - expect(rowElement, "Unable to locate target timeline row.").toBeTruthy(); - expect(virtualRowElement, "Unable to locate target virtualized wrapper.").toBeTruthy(); - expect(timelineRoot, "Unable to locate MessagesTimeline root.").toBeTruthy(); - - timelineWidthPx = timelineRoot!.getBoundingClientRect().width; - actualHeightPx = rowElement!.getBoundingClientRect().height; - virtualizerSizePx = Number.parseFloat(virtualRowElement!.dataset.virtualRowSize ?? "0"); - renderedInVirtualizedRegion = virtualRowElement!.hasAttribute("data-index"); - - expect(timelineWidthPx).toBeGreaterThan(0); - expect(actualHeightPx).toBeGreaterThan(0); - expect(virtualizerSizePx).toBeGreaterThan(0); - expect(renderedInVirtualizedRegion).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); - - const rows = deriveMessagesTimelineRows({ - timelineEntries: input.props.timelineEntries, - completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId, - isWorking: input.props.isWorking, - activeTurnStartedAt: input.props.activeTurnStartedAt, - }); - const targetRow = rows.find((row) => row.id === input.targetRowId); - expect(targetRow, `Unable to derive target row ${input.targetRowId}.`).toBeTruthy(); - - return { - actualHeightPx, - estimatedHeightPx: estimateMessagesTimelineRowHeight(targetRow!, { - expandedWorkGroups: input.props.expandedWorkGroups, - timelineWidthPx, - turnDiffSummaryByAssistantMessageId: input.props.turnDiffSummaryByAssistantMessageId, - }), - timelineWidthPx, - virtualizerSizePx, - renderedInVirtualizedRegion, - }; -} - -async function mountMessagesTimeline(input: { - props: Omit, "scrollContainer">; - viewport?: { width: number; height: number }; -}) { - const viewport = input.viewport ?? DEFAULT_VIEWPORT; - await setViewport(viewport); - await waitForProductionStyles(); - - const host = document.createElement("div"); - host.style.width = `${viewport.width}px`; - host.style.minWidth = `${viewport.width}px`; - host.style.maxWidth = `${viewport.width}px`; - host.style.height = `${viewport.height}px`; - host.style.minHeight = `${viewport.height}px`; - host.style.maxHeight = `${viewport.height}px`; - host.style.display = "block"; - host.style.overflow = "hidden"; - document.body.append(host); - - const screen = await render(, { - container: host, - }); - await waitForLayout(); - - return { - host, - rerender: async ( - nextProps: Omit, "scrollContainer">, - ) => { - await screen.rerender(); - await waitForLayout(); - }, - setContainerSize: async (nextViewport: { width: number; height: number }) => { - await setViewport(nextViewport); - host.style.width = `${nextViewport.width}px`; - host.style.minWidth = `${nextViewport.width}px`; - host.style.maxWidth = `${nextViewport.width}px`; - host.style.height = `${nextViewport.height}px`; - host.style.minHeight = `${nextViewport.height}px`; - host.style.maxHeight = `${nextViewport.height}px`; - await waitForLayout(); - }, - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -async function measureRenderedRowActualHeight(input: { - host: HTMLElement; - targetRowId: string; -}): Promise { - const rowElement = await waitForElement( - () => input.host.querySelector(`[data-timeline-row-id="${input.targetRowId}"]`), - `Unable to locate rendered row ${input.targetRowId}.`, - ); - return rowElement.getBoundingClientRect().height; -} - -describe("MessagesTimeline virtualization harness", () => { - beforeEach(async () => { - document.body.innerHTML = ""; - await setViewport(DEFAULT_VIEWPORT); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => { - const mounted = await mountMessagesTimeline({ props: scenario.props }); - - try { - const measurement = await measureTimelineRow({ - host: mounted.host, - props: scenario.props, - targetRowId: scenario.targetRowId, - }); - - expect( - Math.abs(measurement.actualHeightPx - measurement.estimatedHeightPx), - `estimate delta for ${scenario.name}`, - ).toBeLessThanOrEqual(scenario.maxEstimateDeltaPx); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the changed-files row virtualizer size in sync after collapsing directories", async () => { - const beforeMessages = createFillerMessages({ - prefix: "before-collapse", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after-collapse", - startOffsetSeconds: 40, - pairCount: 8, - }); - const targetMessage = createMessage({ - id: "target-assistant-collapse", - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - const props = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ - { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", - additions: 4, - deletions: 3, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", - additions: 131, - deletions: 128, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", - additions: 1, - deletions: 1, - }, - { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, - { - path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", - additions: 106, - deletions: 112, - }, - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/web/src/components/chat/MessagesTimeline.tsx", - additions: 52, - deletions: 7, - }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.tsx", - additions: 32, - deletions: 4, - }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ]), - }); - const mounted = await mountMessagesTimeline({ - props, - viewport: { width: 320, height: 700 }, - }); - - try { - const beforeCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - const targetRowElement = mounted.host.querySelector( - `[data-timeline-row-id="${targetMessage.id}"]`, - ); - expect(targetRowElement, "Unable to locate target changed-files row.").toBeTruthy(); - - const collapseAllButton = - Array.from(targetRowElement!.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "Collapse all", - ) ?? null; - expect(collapseAllButton, 'Unable to find "Collapse all" button.').toBeTruthy(); - - collapseAllButton!.click(); - - await vi.waitFor( - async () => { - const afterCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - expect(afterCollapse.actualHeightPx).toBeLessThan(beforeCollapse.actualHeightPx - 24); - }, - { timeout: 8_000, interval: 16 }, - ); - - const afterCollapse = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: targetMessage.id, - }); - expect( - Math.abs(afterCollapse.actualHeightPx - afterCollapse.virtualizerSizePx), - ).toBeLessThanOrEqual(8); - } finally { - await mounted.cleanup(); - } - }); - - it("keeps the work-log row virtualizer size in sync after show more expands the group", async () => { - const beforeMessages = createFillerMessages({ - prefix: "before-worklog-expand", - startOffsetSeconds: 0, - pairCount: 2, - }); - const afterMessages = createFillerMessages({ - prefix: "after-worklog-expand", - startOffsetSeconds: 40, - pairCount: 8, - }); - const workEntries = Array.from({ length: 10 }, (_, index) => - createToolWorkEntry({ - id: `target-work-toggle-${index}`, - offsetSeconds: 12 + index, - detail: `tool output line ${index + 1}`, - }), - ); - const props = createBaseTimelineProps({ - messages: [...beforeMessages, ...afterMessages], - workEntries, - }); - const mounted = await mountMessagesTimeline({ props }); - - try { - const beforeExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - const targetRowElement = mounted.host.querySelector( - `[data-timeline-row-id="${workEntries[0]!.id}"]`, - ); - expect(targetRowElement, "Unable to locate target work-log row.").toBeTruthy(); - - const showMoreButton = - Array.from(targetRowElement!.querySelectorAll("button")).find((button) => - button.textContent?.includes("Show 4 more"), - ) ?? null; - expect(showMoreButton, 'Unable to find "Show more" button.').toBeTruthy(); - - showMoreButton!.click(); - - await vi.waitFor( - async () => { - const afterExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - expect(afterExpand.actualHeightPx).toBeGreaterThan(beforeExpand.actualHeightPx + 72); - }, - { timeout: 8_000, interval: 16 }, - ); - - const afterExpand = await measureTimelineRow({ - host: mounted.host, - props, - targetRowId: workEntries[0]!.id, - }); - expect( - Math.abs(afterExpand.actualHeightPx - afterExpand.virtualizerSizePx), - ).toBeLessThanOrEqual(8); - } finally { - await mounted.cleanup(); - } - }); - - it("preserves measured tail row heights when rows transition into virtualization", async () => { - const beforeMessages = createFillerMessages({ - prefix: "tail-transition-before", - startOffsetSeconds: 0, - pairCount: 1, - }); - const afterMessages = createFillerMessages({ - prefix: "tail-transition-after", - startOffsetSeconds: 40, - pairCount: 3, - }); - const targetMessage = createMessage({ - id: "target-tail-transition", - role: "assistant", - text: "Validation passed on the merged tree.", - offsetSeconds: 12, - }); - let latestSnapshot: VirtualizerSnapshot | null = null; - const initialProps = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ - { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", - additions: 4, - deletions: 3, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", - additions: 131, - deletions: 128, - }, - { - path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", - additions: 1, - deletions: 1, - }, - { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, - { - path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", - additions: 106, - deletions: 112, - }, - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, - { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, - { - path: "apps/web/src/components/chat/MessagesTimeline.tsx", - additions: 52, - deletions: 7, - }, - { - path: "apps/web/src/components/chat/ChangedFilesTree.tsx", - additions: 32, - deletions: 4, - }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - ]), - onVirtualizerSnapshot: (snapshot) => { - latestSnapshot = { - totalSize: snapshot.totalSize, - measurements: snapshot.measurements, - }; - }, - }); - - const mounted = await mountMessagesTimeline({ props: initialProps }); - - try { - const initiallyRenderedHeight = await measureRenderedRowActualHeight({ - host: mounted.host, - targetRowId: targetMessage.id, - }); - - const appendedProps = createBaseTimelineProps({ - messages: [ - ...beforeMessages, - targetMessage, - ...afterMessages, - ...createFillerMessages({ - prefix: "tail-transition-extra", - startOffsetSeconds: 120, - pairCount: 8, - }), - ], - turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId, - onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, - }); - await mounted.rerender(appendedProps); - - const scrollContainer = await waitForElement( - () => - mounted.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - scrollContainer.scrollTop = scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - await vi.waitFor( - () => { - const measurement = latestSnapshot?.measurements.find( - (entry) => entry.id === targetMessage.id, - ); - expect( - measurement, - "Expected target row to transition into virtualizer cache.", - ).toBeTruthy(); - expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( - 8, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("preserves measured tail image row heights when rows transition into virtualization", async () => { - const beforeMessages = createFillerMessages({ - prefix: "tail-image-before", - startOffsetSeconds: 0, - pairCount: 1, - }); - const afterMessages = createFillerMessages({ - prefix: "tail-image-after", - startOffsetSeconds: 40, - pairCount: 3, - }); - const targetMessage = createMessage({ - id: "target-tail-image-transition", - role: "user", - text: "Here is a narrow screenshot.", - offsetSeconds: 12, - attachments: [ - { - type: "image", - id: "target-tail-image", - name: "narrow.svg", - mimeType: "image/svg+xml", - sizeBytes: 512, - previewUrl: - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='72'%3E%3Crect width='240' height='72' fill='%23dbeafe'/%3E%3C/svg%3E", - }, - ], - }); - let latestSnapshot: VirtualizerSnapshot | null = null; - const initialProps = createBaseTimelineProps({ - messages: [...beforeMessages, targetMessage, ...afterMessages], - onVirtualizerSnapshot: (snapshot) => { - latestSnapshot = { - totalSize: snapshot.totalSize, - measurements: snapshot.measurements, - }; - }, - }); - const mounted = await mountMessagesTimeline({ props: initialProps }); - - try { - await vi.waitFor( - () => { - const image = mounted.host.querySelector( - `[data-timeline-row-id="${targetMessage.id}"] img`, - ); - expect(image?.naturalHeight ?? 0).toBeGreaterThan(0); - }, - { timeout: 8_000, interval: 16 }, - ); - - const initiallyRenderedHeight = await measureRenderedRowActualHeight({ - host: mounted.host, - targetRowId: targetMessage.id, - }); - const appendedProps = createBaseTimelineProps({ - messages: [ - ...beforeMessages, - targetMessage, - ...afterMessages, - ...createFillerMessages({ - prefix: "tail-image-extra", - startOffsetSeconds: 120, - pairCount: 8, - }), - ], - onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, - }); - await mounted.rerender(appendedProps); - - const scrollContainer = await waitForElement( - () => - mounted.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - scrollContainer.scrollTop = scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - - await vi.waitFor( - () => { - const measurement = latestSnapshot?.measurements.find( - (entry) => entry.id === targetMessage.id, - ); - expect( - measurement, - "Expected target image row to transition into virtualizer cache.", - ).toBeTruthy(); - expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( - 8, - ); - }, - { timeout: 8_000, interval: 16 }, - ); - } finally { - await mounted.cleanup(); - } - }); -}); From 9201b7a5d80d6df0f939a388b8a2033cfc19889e Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 10 Apr 2026 13:22:37 +0300 Subject: [PATCH 5/5] feat(chat): add text reveal animation on message completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce TextRevealContainer component that triggers a sweep-down mask animation when assistant messages finish streaming - Animation duration auto-scales based on text length (0.7ms per char, clamped 400–1600ms) - Uses performant CSS mask-image + keyframe (no JS animation loop) - Remove virtualization test assertions (renderedInVirtualizedRegion) — no longer applicable with content-visibility: auto rendering --- apps/web/src/components/ChatView.browser.tsx | 9 +-- .../src/components/chat/MessagesTimeline.tsx | 26 +++++---- apps/web/src/components/chat/TextReveal.tsx | 56 +++++++++++++++++++ apps/web/src/index.css | 30 ++++++++++ 4 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/chat/TextReveal.tsx diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index ce29c1f9634..8e315f20448 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1211,11 +1211,9 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + const { measuredRowHeightPx, timelineWidthMeasuredPx } = await mounted.measureUserRow(targetMessageId); - expect(renderedInVirtualizedRegion).toBe(true); - const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, { timelineWidthPx: timelineWidthMeasuredPx }, @@ -1254,7 +1252,6 @@ describe("ChatView timeline estimator parity (full app)", () => { { timelineWidthPx: measurement.timelineWidthMeasuredPx }, ); - expect(measurement.renderedInVirtualizedRegion).toBe(true); expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( viewport.textTolerancePx, ); @@ -1331,11 +1328,9 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + const { measuredRowHeightPx, timelineWidthMeasuredPx } = await mounted.measureUserRow(targetMessageId); - expect(renderedInVirtualizedRegion).toBe(true); - const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c7f4bd12155..cf4ac70b25a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -43,6 +43,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; +import TextRevealContainer from "./TextReveal"; import { MAX_VISIBLE_WORK_LOG_ENTRIES, deriveMessagesTimelineRows, @@ -335,17 +336,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({
)}
- - - + + + + {(() => { const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); if (!turnSummary) return null; diff --git a/apps/web/src/components/chat/TextReveal.tsx b/apps/web/src/components/chat/TextReveal.tsx new file mode 100644 index 00000000000..7ef99e12852 --- /dev/null +++ b/apps/web/src/components/chat/TextReveal.tsx @@ -0,0 +1,56 @@ +import { memo, useEffect, useRef, useState, type ReactNode } from "react"; + +const MIN_DURATION_MS = 400; +const MAX_DURATION_MS = 1600; +const MS_PER_CHAR = 0.7; + +function computeRevealDuration(textLength: number): number { + return Math.min(Math.max(textLength * MS_PER_CHAR, MIN_DURATION_MS), MAX_DURATION_MS); +} + +interface TextRevealContainerProps { + children: ReactNode; + isStreaming: boolean; + textLength: number; +} + +function TextRevealContainer({ children, isStreaming, textLength }: TextRevealContainerProps) { + const wasStreamingRef = useRef(isStreaming); + const [animating, setAnimating] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (wasStreamingRef.current && !isStreaming && textLength > 0) { + setAnimating(true); + } + wasStreamingRef.current = isStreaming; + }, [isStreaming, textLength]); + + useEffect(() => { + if (!animating) return; + const el = containerRef.current; + if (!el) return; + + const handleEnd = () => setAnimating(false); + el.addEventListener("animationend", handleEnd, { once: true }); + return () => el.removeEventListener("animationend", handleEnd); + }, [animating]); + + const durationMs = animating ? computeRevealDuration(textLength) : undefined; + + return ( +
+ {children} +
+ ); +} + +export default memo(TextRevealContainer); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4bb3311d1dd..78c9cc85bd6 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -514,3 +514,33 @@ label:has(> select#reasoning-effort) select { -webkit-background-clip: text; animation: ultrathink-rainbow 10s linear infinite; } + +/* Text reveal animation for completed assistant messages */ +@keyframes text-reveal-sweep { + from { + -webkit-mask-position: 0% 100%; + mask-position: 0% 100%; + } + to { + -webkit-mask-position: 0% 0%; + mask-position: 0% 0%; + } +} + +.text-reveal-animating { + --_reveal-grad: linear-gradient( + to bottom, + black 0%, + black 35%, + rgba(0, 0, 0, 0.6) 48%, + rgba(0, 0, 0, 0.2) 58%, + transparent 68%, + transparent 100% + ); + -webkit-mask-image: var(--_reveal-grad); + mask-image: var(--_reveal-grad); + -webkit-mask-size: 100% 300%; + mask-size: 100% 300%; + animation: text-reveal-sweep var(--text-reveal-duration, 1000ms) cubic-bezier(0.22, 0.61, 0.36, 1) + forwards; +}