From f76d49993ba803bee004601f6989f0db66cc1f48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 1 Apr 2026 10:32:22 -0700 Subject: [PATCH 1/9] test: add timeline virtualization mismatch harness Co-authored-by: codex --- .../components/chat/ChangedFilesTree.test.tsx | 26 + .../components/chat/MessagesTimeline.logic.ts | 118 ++++ .../src/components/chat/MessagesTimeline.tsx | 123 +--- ...essagesTimeline.virtualization.browser.tsx | 541 ++++++++++++++++++ 4 files changed, 709 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/components/chat/ChangedFilesTree.test.tsx create mode 100644 apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx new file mode 100644 index 0000000000..807ebb248f --- /dev/null +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -0,0 +1,26 @@ +import { TurnId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ChangedFilesTree } from "./ChangedFilesTree"; + +describe("ChangedFilesTree", () => { + it("renders nested directories collapsed on the first render when collapse-all is active", () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain("apps/web/src"); + expect(markup).not.toContain("index.ts"); + expect(markup).not.toContain("main.ts"); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 726d61888e..682861a22b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,3 +1,7 @@ +import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; +import { type ChatMessage, type ProposedPlan } from "../../types"; +import { estimateTimelineMessageHeight } from "../timelineHeight"; + export interface TimelineDurationMessage { id: string; role: "user" | "assistant" | "system"; @@ -5,6 +9,29 @@ export interface TimelineDurationMessage { completedAt?: string | undefined; } +export type MessagesTimelineRow = + | { + kind: "work"; + id: string; + createdAt: string; + groupedEntries: WorkLogEntry[]; + } + | { + kind: "message"; + id: string; + createdAt: string; + message: ChatMessage; + durationStart: string; + showCompletionDivider: boolean; + } + | { + kind: "proposed-plan"; + id: string; + createdAt: string; + proposedPlan: ProposedPlan; + } + | { kind: "working"; id: string; createdAt: string | null }; + export function computeMessageDurationStart( messages: ReadonlyArray, ): Map { @@ -27,3 +54,94 @@ export function computeMessageDurationStart( export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } + +export function deriveMessagesTimelineRows(input: { + timelineEntries: ReadonlyArray; + completionDividerBeforeEntryId: string | null; + isWorking: boolean; + activeTurnStartedAt: string | null; +}): MessagesTimelineRow[] { + const nextRows: MessagesTimelineRow[] = []; + const durationStartByMessageId = computeMessageDurationStart( + input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + ); + + for (let index = 0; index < input.timelineEntries.length; index += 1) { + const timelineEntry = input.timelineEntries[index]; + if (!timelineEntry) { + continue; + } + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const nextEntry = input.timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + nextRows.push({ + kind: "work", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + groupedEntries, + }); + index = cursor - 1; + continue; + } + + if (timelineEntry.kind === "proposed-plan") { + nextRows.push({ + kind: "proposed-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + proposedPlan: timelineEntry.proposedPlan, + }); + continue; + } + + nextRows.push({ + kind: "message", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + message: timelineEntry.message, + durationStart: + durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, + showCompletionDivider: + timelineEntry.message.role === "assistant" && + input.completionDividerBeforeEntryId === timelineEntry.id, + }); + } + + if (input.isWorking) { + nextRows.push({ + kind: "working", + id: "working-indicator-row", + createdAt: input.activeTurnStartedAt, + }); + } + + return nextRows; +} + +export function estimateMessagesTimelineRowHeight( + row: MessagesTimelineRow, + input: { timelineWidthPx: number | null }, +): number { + switch (row.kind) { + case "work": + return 112; + case "proposed-plan": + return estimateTimelineProposedPlanHeight(row.proposedPlan); + case "working": + return 40; + case "message": + return estimateTimelineMessageHeight(row.message, { timelineWidthPx: input.timelineWidthPx }); + } +} + +function estimateTimelineProposedPlanHeight(proposedPlan: ProposedPlan): number { + const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); + return 120 + Math.min(estimatedLines * 22, 880); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9e0b895912..8fcf09de06 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -35,13 +35,17 @@ import { } from "lucide-react"; import { Button } from "../ui/button"; import { clamp } from "effect/Number"; -import { estimateTimelineMessageHeight } from "../timelineHeight"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + deriveMessagesTimelineRows, + estimateMessagesTimelineRowHeight, + normalizeCompactToolLabel, + type MessagesTimelineRow, +} from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { deriveDisplayedUserMessageState, @@ -134,70 +138,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [hasMessages, isWorking]); - const rows = useMemo(() => { - const nextRows: TimelineRow[] = []; - const durationStartByMessageId = computeMessageDurationStart( - timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), - ); - - for (let index = 0; index < timelineEntries.length; index += 1) { - const timelineEntry = timelineEntries[index]; - if (!timelineEntry) { - continue; - } - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - nextRows.push({ - kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries, - }); - index = cursor - 1; - continue; - } - - if (timelineEntry.kind === "proposed-plan") { - nextRows.push({ - kind: "proposed-plan", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - proposedPlan: timelineEntry.proposedPlan, - }); - continue; - } - - nextRows.push({ - kind: "message", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - message: timelineEntry.message, - durationStart: - durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, - showCompletionDivider: - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id, - }); - } - - if (isWorking) { - nextRows.push({ - kind: "working", - id: "working-indicator-row", - createdAt: activeTurnStartedAt, - }); - } - - return nextRows; - }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); + const rows = useMemo( + () => + deriveMessagesTimelineRows({ + timelineEntries, + completionDividerBeforeEntryId, + isWorking, + activeTurnStartedAt, + }), + [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], + ); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); @@ -250,10 +200,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ estimateSize: (index: number) => { const row = rows[index]; if (!row) return 96; - if (row.kind === "work") return 112; - if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); - if (row.kind === "working") return 40; - return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); + return estimateMessagesTimelineRowHeight(row, { timelineWidthPx }); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, @@ -311,6 +258,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const renderRowContent = (row: TimelineRow) => (
[number]; type TimelineMessage = Extract["message"]; -type TimelineProposedPlan = Extract["proposedPlan"]; -type TimelineWorkEntry = Extract["entry"]; -type TimelineRow = - | { - kind: "work"; - id: string; - createdAt: string; - groupedEntries: TimelineWorkEntry[]; - } - | { - kind: "message"; - id: string; - createdAt: string; - message: TimelineMessage; - durationStart: string; - showCompletionDivider: boolean; - } - | { - kind: "proposed-plan"; - id: string; - createdAt: string; - proposedPlan: TimelineProposedPlan; - } - | { kind: "working"; id: string; createdAt: string | null }; - -function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { - const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); - return 120 + Math.min(estimatedLines * 22, 880); -} +type TimelineWorkEntry = Extract["groupedEntries"][number]; +type TimelineRow = MessagesTimelineRow; function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx new file mode 100644 index 0000000000..7365d34911 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -0,0 +1,541 @@ +import "../../index.css"; + +import { MessageId, type TurnId } from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { 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; +} + +function MessagesTimelineBrowserHarness( + props: Omit, "scrollContainer">, +) { + const [scrollContainer, setScrollContainer] = useState(null); + + 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; +}): ChatMessage { + return { + id: MessageId.makeUnsafe(input.id), + role: input.role, + text: input.text, + 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[]; + completionDividerBeforeEntryId?: string | null; + turnDiffSummaryByAssistantMessageId?: Map; +}): Omit, "scrollContainer"> { + return { + 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: {}, + onToggleWorkGroup: () => {}, + onOpenTurnDiff: () => {}, + revertTurnCountByUserMessageId: new Map(), + onRevertUserMessage: () => {}, + isRevertingCheckpoint: false, + onImageExpand: () => {}, + markdownCwd: MARKDOWN_CWD, + resolvedTheme: "light", + timestampFormat: "locale", + workspaceRoot: MARKDOWN_CWD, + }; +} + +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): Map { + return new Map([ + [ + targetMessageId, + { + turnId: "turn-changed-files" as TurnId, + completedAt: isoAt(10), + assistantMessageId: targetMessageId, + files: [ + { 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 }, + ], + }, + ], + ]); +} + +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"), + }); + const changedFilesMessage = createMessage({ + id: "target-assistant-changed-files", + role: "assistant", + text: "Validation passed on the merged tree.", + offsetSeconds: 12, + }); + + 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: "proposed plan row", + targetRowId: moderatePlan.id, + props: createBaseTimelineProps({ + messages: [...beforeMessages, ...afterMessages], + proposedPlans: [moderatePlan], + }), + maxEstimateDeltaPx: 96, + }, + { + name: "assistant changed-files row", + targetRowId: changedFilesMessage.id, + props: createBaseTimelineProps({ + messages: [...beforeMessages, changedFilesMessage, ...afterMessages], + turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(changedFilesMessage.id), + }), + maxEstimateDeltaPx: 72, + }, + ]; +} + +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!, { timelineWidthPx }), + timelineWidthPx, + virtualizerSizePx, + renderedInVirtualizedRegion, + }; +} + +async function mountMessagesTimeline(input: { + props: Omit, "scrollContainer">; +}) { + await setViewport(DEFAULT_VIEWPORT); + await waitForProductionStyles(); + + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.width = "100vw"; + host.style.height = "100vh"; + host.style.display = "grid"; + host.style.overflow = "hidden"; + document.body.append(host); + + const screen = await render(, { + container: host, + }); + await waitForLayout(); + + return { + host, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +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), + }); + const mounted = await mountMessagesTimeline({ props }); + + 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(); + } + }); +}); From 2f6f4302a6aa6644d73ef4c785c1da9351c9a071 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 1 Apr 2026 10:33:24 -0700 Subject: [PATCH 2/9] fix: respect initial changed-files collapse state Co-authored-by: codex --- .../src/components/chat/ChangedFilesTree.tsx | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index a13ddad30d..29bd96ea05 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,5 +1,5 @@ import { type TurnId } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; @@ -7,6 +7,8 @@ import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; +const EMPTY_DIRECTORY_OVERRIDES: Record = {}; + export const ChangedFilesTree = memo(function ChangedFilesTree(props: { turnId: TurnId; files: ReadonlyArray; @@ -20,32 +22,39 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { () => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes], ); - const allDirectoryExpansionState = useMemo( - () => - buildDirectoryExpansionState( - directoryPathsKey ? directoryPathsKey.split("\u0000") : [], - allDirectoriesExpanded, - ), - [allDirectoriesExpanded, directoryPathsKey], - ); - const [expandedDirectories, setExpandedDirectories] = useState>(() => - buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), - ); - useEffect(() => { - setExpandedDirectories(allDirectoryExpansionState); - }, [allDirectoryExpansionState]); + const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`; + const [directoryExpansionState, setDirectoryExpansionState] = useState<{ + key: string; + overrides: Record; + }>(() => ({ + key: expansionStateKey, + overrides: {}, + })); + const expandedDirectories = + directoryExpansionState.key === expansionStateKey + ? directoryExpansionState.overrides + : EMPTY_DIRECTORY_OVERRIDES; - const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { - setExpandedDirectories((current) => ({ - ...current, - [pathValue]: !(current[pathValue] ?? fallbackExpanded), - })); - }, []); + const toggleDirectory = useCallback( + (pathValue: string) => { + setDirectoryExpansionState((current) => { + const nextOverrides = current.key === expansionStateKey ? current.overrides : {}; + return { + key: expansionStateKey, + overrides: { + ...nextOverrides, + [pathValue]: !(nextOverrides[pathValue] ?? allDirectoriesExpanded), + }, + }; + }); + }, + [allDirectoriesExpanded, expansionStateKey], + ); const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { const leftPadding = 8 + depth * 14; if (node.kind === "directory") { - const isExpanded = expandedDirectories[node.path] ?? depth === 0; + const isExpanded = expandedDirectories[node.path] ?? allDirectoriesExpanded; return (