diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx new file mode 100644 index 000000000..1995cb46a --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -0,0 +1,541 @@ +// Production CSS is part of the behavior under test because row height depends on it. +import "../../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useMemo, useState } from "react"; +import { render } from "vitest-browser-react"; + +import { type TimestampFormat } from "../../appSettings"; +import { + buildGeneratedTimelineHeightRows, + generateTimelineHeightEdgeCases, + generateTimelineHeightThreadCases, + type GeneratedTimelineHeightEdgeCase, + type GeneratedTimelineHeightResolvedRow, + type GeneratedTimelineHeightThreadCase, +} from "../timelineHeight.generated"; +import { estimateTimelineRowHeight } from "../timelineHeight"; +import { MessagesTimeline } from "./MessagesTimeline"; + +interface ViewportSpec { + name: string; + width: number; + height: number; +} + +const DESKTOP_VIEWPORT: ViewportSpec = { name: "desktop", width: 960, height: 1_100 }; +const MOBILE_VIEWPORT: ViewportSpec = { name: "mobile", width: 430, height: 932 }; +const DESKTOP_BROWSER_CASE_COUNT = 6; +const MOBILE_EDGE_CASE_NAMES: GeneratedTimelineHeightEdgeCase["name"][] = [ + "attachment-fallback", + "streaming-messages", +]; + +function noop(): void {} + +function nextFrame(): Promise { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); +} + +async function waitForLayout(): Promise { + await nextFrame(); + await nextFrame(); +} + +async function waitForElement( + query: () => T | null, + errorMessage: string, +): Promise { + let element: T | null = null; + + await vi.waitFor( + () => { + element = query(); + expect(element, errorMessage).toBeTruthy(); + }, + { + timeout: 4_000, + interval: 16, + }, + ); + + if (!element) { + throw new Error(errorMessage); + } + + return element; +} + +async function waitForProductionStyles(): Promise { + await vi.waitFor( + () => { + expect( + getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), + ).not.toBe(""); + }, + { + timeout: 4_000, + interval: 16, + }, + ); +} + +async function waitForImagesToLoad(scope: ParentNode): Promise { + const images = Array.from(scope.querySelectorAll("img")); + if (images.length === 0) { + return; + } + + await Promise.all( + images.map( + (image) => + new Promise((resolve) => { + if (image.complete) { + resolve(); + return; + } + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + }), + ), + ); + await waitForLayout(); +} + +function rowTolerancePx( + row: GeneratedTimelineHeightResolvedRow["input"], + viewport: ViewportSpec, + estimatedHeightPx: number, +): number { + if (row.kind === "working") { + return 8; + } + if (row.kind === "work") { + return Math.max(viewport.name === "mobile" ? 128 : 112, Math.round(estimatedHeightPx * 0.14)); + } + if (row.kind === "proposed-plan") { + return Math.max(112, Math.round(estimatedHeightPx * 0.12)); + } + if (row.message.role === "user") { + const baseTolerancePx = + viewport.name === "mobile" ? 136 : (row.message.attachments?.length ?? 0) > 0 ? 128 : 56; + const proportionalTolerancePx = Math.round( + estimatedHeightPx * (viewport.name === "mobile" ? 0.18 : 0.14), + ); + return Math.max(baseTolerancePx, proportionalTolerancePx); + } + if (row.diffSummary) { + return Math.max( + viewport.name === "mobile" ? 128 : 96, + Math.round(estimatedHeightPx * (viewport.name === "mobile" ? 0.42 : 0.14)), + ); + } + if (row.showCompletionDivider) { + return Math.max( + viewport.name === "mobile" ? 120 : 72, + Math.round(estimatedHeightPx * (viewport.name === "mobile" ? 0.42 : 0.14)), + ); + } + return Math.max( + viewport.name === "mobile" ? 168 : 56, + Math.round(estimatedHeightPx * (viewport.name === "mobile" ? 0.22 : 0.14)), + ); +} + +function GeneratedMessagesTimelineHarness(props: { + generatedCase: GeneratedTimelineHeightThreadCase; + expandedWorkGroupIds?: string[]; + activeTurnInProgress?: boolean; +}) { + const [scrollContainer, setScrollContainer] = useState(null); + const expandedWorkGroups = useMemo( + () => Object.fromEntries((props.expandedWorkGroupIds ?? []).map((groupId) => [groupId, true])), + [props.expandedWorkGroupIds], + ); + const activeTurnStartedAt = + props.generatedCase.thread.timelineEntries.find((entry) => entry.kind === "work")?.createdAt ?? + null; + + return ( +
+
+ +
+
+ ); +} + +async function mountGeneratedTimeline(options: { + generatedCase: GeneratedTimelineHeightThreadCase; + expandedWorkGroupIds?: string[]; + activeTurnInProgress?: boolean; + viewport: ViewportSpec; +}) { + await page.viewport(options.viewport.width, options.viewport.height); + 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.overflow = "hidden"; + document.body.append(host); + + const screen = await render( + , + { container: host }, + ); + + await waitForLayout(); + + const scrollContainer = host.querySelector( + "div.overflow-y-auto.overscroll-y-contain", + ); + if (!(scrollContainer instanceof HTMLDivElement)) { + throw new Error("Unable to locate timeline scroll container."); + } + + const timelineRoot = host.querySelector('[data-timeline-root="true"]'); + if (!(timelineRoot instanceof HTMLElement)) { + throw new Error("Unable to locate timeline root."); + } + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + host, + scrollContainer, + timelineRoot, + }; +} + +function findRowElement(host: HTMLElement, rowId: string): HTMLElement | null { + return host.querySelector(`[data-timeline-row-id="${rowId}"]`); +} + +async function measureRenderedRowHeight(options: { + host: HTMLElement; + scrollContainer: HTMLDivElement; + rowId: string; +}): Promise { + let measuredHeightPx = 0; + + await vi.waitFor( + async () => { + const row = options.host.querySelector( + `[data-timeline-row-id="${options.rowId}"]`, + ); + expect(row, `Unable to locate row ${options.rowId}.`).toBeTruthy(); + row!.scrollIntoView({ block: "center" }); + options.scrollContainer.dispatchEvent(new Event("scroll")); + await waitForImagesToLoad(row!); + await waitForLayout(); + measuredHeightPx = row!.getBoundingClientRect().height; + expect(measuredHeightPx, `Unable to measure row ${options.rowId}.`).toBeGreaterThan(0); + }, + { + timeout: 4_000, + interval: 16, + }, + ); + + return measuredHeightPx; +} + +async function measureRenderedRowHeightAtCurrentScroll(options: { + host: HTMLElement; + rowId: string; +}): Promise { + return waitForElement( + () => findRowElement(options.host, options.rowId), + `Unable to locate row ${options.rowId}.`, + ).then(async (row) => { + await waitForImagesToLoad(row); + await waitForLayout(); + return row.getBoundingClientRect().height; + }); +} + +async function findButtonWithinRow(options: { + host: HTMLElement; + rowId: string; + label: string; +}): Promise { + return waitForElement( + () => + Array.from( + findRowElement(options.host, options.rowId)?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent?.trim() === options.label) ?? null, + `Unable to find "${options.label}" button within row ${options.rowId}.`, + ); +} + +async function clickButtonWithinRow(options: { + host: HTMLElement; + rowId: string; + label: string; +}): Promise { + const button = await findButtonWithinRow(options); + button.click(); + await waitForLayout(); +} + +async function assertGeneratedCaseMatchesEstimatedHeights(options: { + generatedCase: GeneratedTimelineHeightThreadCase; + expandedWorkGroupIds?: string[]; + activeTurnInProgress?: boolean; + viewport: ViewportSpec; +}) { + const expectedRows = options.expandedWorkGroupIds + ? buildGeneratedTimelineHeightRows(options.generatedCase, { + expandedWorkGroupIds: options.expandedWorkGroupIds, + }) + : buildGeneratedTimelineHeightRows(options.generatedCase); + const mounted = await mountGeneratedTimeline(options); + + try { + await waitForImagesToLoad(mounted.host); + await waitForLayout(); + + const timelineWidthPx = mounted.timelineRoot.getBoundingClientRect().width; + expect(timelineWidthPx).toBeGreaterThan(0); + + for (const expectedRow of expectedRows) { + const measuredHeightPx = await measureRenderedRowHeight({ + host: mounted.host, + scrollContainer: mounted.scrollContainer, + rowId: expectedRow.id, + }); + const estimatedHeightPx = estimateTimelineRowHeight(expectedRow.input, { + timelineWidthPx, + }); + const tolerancePx = rowTolerancePx(expectedRow.input, options.viewport, estimatedHeightPx); + const deltaPx = Math.abs(measuredHeightPx - estimatedHeightPx); + + expect( + deltaPx, + `row ${expectedRow.id} at ${options.viewport.name} should stay close to the estimator`, + ).toBeLessThanOrEqual(tolerancePx); + } + } finally { + await mounted.cleanup(); + } +} + +describe("MessagesTimeline browser height parity", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("keeps generated thread rows close to the estimator at the desktop viewport", async () => { + const generatedCases = generateTimelineHeightThreadCases(DESKTOP_BROWSER_CASE_COUNT); + + for (const generatedCase of generatedCases) { + await assertGeneratedCaseMatchesEstimatedHeights({ + generatedCase, + viewport: DESKTOP_VIEWPORT, + }); + } + }, 30_000); + + it("keeps expanded work groups close to the estimator", async () => { + const generatedCases = generateTimelineHeightThreadCases(6); + + for (const generatedCase of generatedCases) { + await assertGeneratedCaseMatchesEstimatedHeights({ + generatedCase, + expandedWorkGroupIds: [generatedCase.ids.workGroup], + viewport: DESKTOP_VIEWPORT, + }); + } + }, 30_000); + + it("keeps selected generated edge cases close to the estimator on mobile", async () => { + const edgeCases = generateTimelineHeightEdgeCases(); + + for (const edgeCaseName of MOBILE_EDGE_CASE_NAMES) { + const edgeCase = edgeCases.find((candidate) => candidate.name === edgeCaseName); + expect(edgeCase, `Missing generated edge case ${edgeCaseName}`).toBeDefined(); + await assertGeneratedCaseMatchesEstimatedHeights({ + generatedCase: edgeCase!.generatedCase, + activeTurnInProgress: edgeCaseName === "streaming-messages", + viewport: MOBILE_VIEWPORT, + }); + } + }, 30_000); + + it("renders fallback attachment tiles without preview images and keeps height parity", async () => { + const generatedCase = generateTimelineHeightEdgeCases().find( + (candidate) => candidate.name === "attachment-fallback", + )!.generatedCase; + const mounted = await mountGeneratedTimeline({ + generatedCase, + viewport: DESKTOP_VIEWPORT, + }); + + try { + const userRowId = generatedCase.ids.user; + const row = await waitForElement( + () => findRowElement(mounted.host, userRowId), + `Unable to locate row ${userRowId}.`, + ); + + expect(row.querySelectorAll("img").length).toBe(0); + expect(row.textContent).toContain("attachment-1.png"); + + const expectedRow = buildGeneratedTimelineHeightRows(generatedCase).find( + (candidate) => candidate.id === userRowId, + ); + expect(expectedRow).toBeDefined(); + const measuredHeightPx = await measureRenderedRowHeight({ + host: mounted.host, + scrollContainer: mounted.scrollContainer, + rowId: userRowId, + }); + const estimatedHeightPx = estimateTimelineRowHeight(expectedRow!.input, { + timelineWidthPx: mounted.timelineRoot.getBoundingClientRect().width, + }); + + expect(Math.abs(measuredHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(128); + } finally { + await mounted.cleanup(); + } + }, 30_000); + + it("renders streaming assistant and system rows with real height parity", async () => { + const generatedCase = generateTimelineHeightEdgeCases().find( + (candidate) => candidate.name === "streaming-messages", + )!.generatedCase; + + await assertGeneratedCaseMatchesEstimatedHeights({ + generatedCase, + activeTurnInProgress: true, + viewport: DESKTOP_VIEWPORT, + }); + }, 30_000); + + it("updates proposed-plan row height when expanding and collapsing a long plan", async () => { + const generatedCase = generateTimelineHeightEdgeCases().find( + (candidate) => candidate.name === "borderline-collapsible-plan", + )!.generatedCase; + const rowId = generatedCase.ids.longPlan; + const mounted = await mountGeneratedTimeline({ + generatedCase, + viewport: DESKTOP_VIEWPORT, + }); + + try { + const collapsedHeightPx = await measureRenderedRowHeight({ + host: mounted.host, + scrollContainer: mounted.scrollContainer, + rowId, + }); + + await clickButtonWithinRow({ + host: mounted.host, + rowId, + label: "Expand plan", + }); + const expandedHeightPx = await measureRenderedRowHeightAtCurrentScroll({ + host: mounted.host, + rowId, + }); + expect(expandedHeightPx).toBeGreaterThan(collapsedHeightPx); + + await clickButtonWithinRow({ + host: mounted.host, + rowId, + label: "Collapse plan", + }); + const collapsedAgainHeightPx = await measureRenderedRowHeightAtCurrentScroll({ + host: mounted.host, + rowId, + }); + expect(Math.abs(collapsedAgainHeightPx - collapsedHeightPx)).toBeLessThanOrEqual(8); + } finally { + await mounted.cleanup(); + } + }, 30_000); + + it("updates diff-summary row height when collapsing and expanding the changed-files tree", async () => { + const generatedCase = generateTimelineHeightEdgeCases().find( + (candidate) => candidate.name === "deep-diff-tree", + )!.generatedCase; + const rowId = generatedCase.ids.assistantDiffSummary; + const mounted = await mountGeneratedTimeline({ + generatedCase, + viewport: DESKTOP_VIEWPORT, + }); + + try { + const expandedHeightPx = await measureRenderedRowHeight({ + host: mounted.host, + scrollContainer: mounted.scrollContainer, + rowId, + }); + + await clickButtonWithinRow({ + host: mounted.host, + rowId, + label: "Collapse all", + }); + const collapsedHeightPx = await measureRenderedRowHeightAtCurrentScroll({ + host: mounted.host, + rowId, + }); + expect(collapsedHeightPx).toBeLessThan(expandedHeightPx); + + await clickButtonWithinRow({ + host: mounted.host, + rowId, + label: "Expand all", + }); + const expandedAgainHeightPx = await measureRenderedRowHeightAtCurrentScroll({ + host: mounted.host, + rowId, + }); + expect(Math.abs(expandedAgainHeightPx - expandedHeightPx)).toBeLessThanOrEqual(12); + } finally { + await mounted.cleanup(); + } + }, 30_000); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 726d61888..32f3ea8f0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,3 +1,8 @@ +import { type MessageId } from "@t3tools/contracts"; + +import { type TimelineEntry } from "../../session-logic"; +import { type TurnDiffSummary } from "../../types"; + export interface TimelineDurationMessage { id: string; role: "user" | "assistant" | "system"; @@ -27,3 +32,108 @@ export function computeMessageDurationStart( export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } + +type TimelineMessage = Extract["message"]; +type TimelineProposedPlan = Extract["proposedPlan"]; +type TimelineWorkEntry = Extract["entry"]; + +export type MessagesTimelineRow = + | { + kind: "work"; + id: string; + createdAt: string; + groupedEntries: TimelineWorkEntry[]; + } + | { + kind: "message"; + id: string; + createdAt: string; + message: TimelineMessage; + assistantDiffSummary: TurnDiffSummary | null; + durationStart: string; + showCompletionDivider: boolean; + } + | { + kind: "proposed-plan"; + id: string; + createdAt: string; + proposedPlan: TimelineProposedPlan; + } + | { kind: "working"; id: string; createdAt: string | null }; + +export function resolveMessagesTimelineRows(options: { + timelineEntries: TimelineEntry[]; + completionDividerBeforeEntryId: string | null; + turnDiffSummaryByAssistantMessageId: Map; + isWorking: boolean; + activeTurnStartedAt: string | null; +}): MessagesTimelineRow[] { + const nextRows: MessagesTimelineRow[] = []; + const durationStartByMessageId = computeMessageDurationStart( + options.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + ); + + for (let index = 0; index < options.timelineEntries.length; index += 1) { + const timelineEntry = options.timelineEntries[index]; + if (!timelineEntry) { + continue; + } + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < options.timelineEntries.length) { + const nextEntry = options.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, + assistantDiffSummary: + timelineEntry.message.role === "assistant" + ? (options.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) ?? null) + : null, + durationStart: + durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, + showCompletionDivider: + timelineEntry.message.role === "assistant" && + options.completionDividerBeforeEntryId === timelineEntry.id, + }); + } + + if (options.isWorking) { + nextRows.push({ + kind: "working", + id: "working-indicator-row", + createdAt: options.activeTurnStartedAt, + }); + } + + return nextRows; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e30801041..4f57559d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -26,13 +26,17 @@ import { } from "lucide-react"; import { Button } from "../ui/button"; import { clamp } from "effect/Number"; -import { estimateTimelineMessageHeight } from "../timelineHeight"; +import { estimateTimelineRowHeight } 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 { + normalizeCompactToolLabel, + resolveMessagesTimelineRows, + type MessagesTimelineRow, +} from "./MessagesTimeline.logic"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; @@ -115,70 +119,23 @@ 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( + () => + resolveMessagesTimelineRows({ + timelineEntries, + completionDividerBeforeEntryId, + turnDiffSummaryByAssistantMessageId, + isWorking, + activeTurnStartedAt, + }), + [ + timelineEntries, + completionDividerBeforeEntryId, + turnDiffSummaryByAssistantMessageId, + isWorking, + activeTurnStartedAt, + ], + ); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); @@ -231,10 +188,34 @@ 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 }); + if (row.kind === "work") { + return estimateTimelineRowHeight( + { + kind: "work", + groupedEntries: row.groupedEntries, + expanded: expandedWorkGroups[row.id] ?? false, + }, + { timelineWidthPx }, + ); + } + if (row.kind === "proposed-plan") { + return estimateTimelineRowHeight( + { kind: "proposed-plan", proposedPlan: row.proposedPlan }, + { timelineWidthPx }, + ); + } + if (row.kind === "working") { + return estimateTimelineRowHeight({ kind: "working" }, { timelineWidthPx }); + } + return estimateTimelineRowHeight( + { + kind: "message", + message: row.message, + showCompletionDivider: row.showCompletionDivider, + diffSummary: row.assistantDiffSummary ? { files: row.assistantDiffSummary.files } : null, + }, + { timelineWidthPx }, + ); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, @@ -288,6 +269,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
@@ -409,12 +391,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ })()} {row.kind === "message" && - row.message.role === "assistant" && + (row.message.role === "assistant" || row.message.role === "system") && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const isAssistantMessage = row.message.role === "assistant"; return ( <> - {row.showCompletionDivider && ( + {isAssistantMessage && row.showCompletionDivider && (
@@ -429,62 +412,63 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cwd={markdownCwd} isStreaming={Boolean(row.message.streaming)} /> - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); - const allDirectoriesExpanded = - allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {hasNonZeroStat(summaryStat) && ( - <> - - - - )} -

-
- - + {isAssistantMessage && + (() => { + const turnSummary = row.assistantDiffSummary; + if (!turnSummary) return null; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; + const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const changedFileCountLabel = String(checkpointFiles.length); + const allDirectoriesExpanded = + allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; + return ( +
+
+

+ Changed files ({changedFileCountLabel}) + {hasNonZeroStat(summaryStat) && ( + <> + + + + )} +

+
+ + +
+
- -
- ); - })()} + ); + })()}

{formatMessageMeta( row.message.createdAt, @@ -572,37 +556,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }); -type TimelineEntry = ReturnType[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 TimelineRow = MessagesTimelineRow; +type TimelineMessage = Extract["message"]; +type TimelineWorkEntry = Extract["groupedEntries"][number]; function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); diff --git a/apps/web/src/components/timelineHeight.generated.ts b/apps/web/src/components/timelineHeight.generated.ts new file mode 100644 index 000000000..2d7c177f1 --- /dev/null +++ b/apps/web/src/components/timelineHeight.generated.ts @@ -0,0 +1,642 @@ +import type { MessageId, OrchestrationProposedPlanId, TurnId } from "@t3tools/contracts"; + +import type { TimelineEntry, WorkLogEntry } from "../session-logic"; +import type { ChatAttachment, ChatMessage, ProposedPlan, TurnDiffSummary } from "../types"; +import { resolveMessagesTimelineRows } from "./chat/MessagesTimeline.logic"; +import type { TimelineRowHeightInput } from "./timelineHeight"; + +const GENERATED_MIN_LINES = 4; +const GENERATED_MAX_LINES = 50; +const ATTACHMENT_SVG = ""; + +export interface GeneratedTimelineHeightThreadCase { + index: number; + ids: { + user: string; + assistantBaseline: string; + assistantMarkdownBaseline: string; + assistantStructured: string; + assistantCompletion: string; + assistantDiffSummary: string; + system: string; + workGroup: string; + shortPlan: string; + longPlan: string; + longerPlan: string; + working: string; + }; + thread: { + timelineEntries: TimelineEntry[]; + completionDividerBeforeEntryId: string | null; + turnDiffSummaryByAssistantMessageId: Map; + isWorking: boolean; + }; +} + +export interface GeneratedTimelineHeightResolvedRow { + id: string; + input: TimelineRowHeightInput; +} + +export interface GeneratedTimelineHeightEdgeCase { + name: + | "dense-table-markdown" + | "long-fenced-code" + | "deep-diff-tree" + | "borderline-collapsible-plan" + | "attachment-fallback" + | "streaming-messages"; + generatedCase: GeneratedTimelineHeightThreadCase; +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let next = Math.imul(state ^ (state >>> 15), 1 | state); + next ^= next + Math.imul(next ^ (next >>> 7), 61 | next); + return ((next ^ (next >>> 14)) >>> 0) / 4294967296; + }; +} + +function randomInt(random: () => number, min: number, max: number): number { + return Math.floor(random() * (max - min + 1)) + min; +} + +function randomWord(random: () => number, minLength = 1, maxLength = 18): string { + const length = randomInt(random, minLength, maxLength); + return Array.from({ length }, () => + String.fromCharCode("a".charCodeAt(0) + randomInt(random, 0, 25)), + ).join(""); +} + +function randomLine(random: () => number, minWords = 2, maxWords = 14): string { + return Array.from({ length: randomInt(random, minWords, maxWords) }, () => + randomWord(random), + ).join(" "); +} + +function randomPlainTextByLineCount( + random: () => number, + minLines = GENERATED_MIN_LINES, + maxLines = GENERATED_MAX_LINES, +): string { + const lineCount = randomInt(random, minLines, maxLines); + return Array.from({ length: lineCount }, () => { + const wordCount = randomInt(random, 2, 18); + return Array.from({ length: wordCount }, () => randomWord(random)).join(" "); + }).join("\n"); +} + +function randomMarkdownTextByLineCount( + random: () => number, + minLines = GENERATED_MIN_LINES, + maxLines = GENERATED_MAX_LINES, +): string { + const lineBudget = randomInt(random, minLines, maxLines); + const lines = [ + `# ${randomLine(random, 2, 6)}`, + "", + randomLine(random, 8, 18), + `- ${randomLine(random, 4, 10)}`, + `- ${randomLine(random, 4, 10)}`, + `1. ${randomLine(random, 4, 10)}`, + `> ${randomLine(random, 5, 12)}`, + "| file | additions | deletions |", + "| --- | ---: | ---: |", + `| ${randomWord(random, 5, 12)}.ts | ${randomInt(random, 1, 99)} | ${randomInt(random, 0, 40)} |`, + "```ts", + `export const ${randomWord(random, 4, 10)} = "${randomWord(random, 12, 24)}";`, + "```", + ]; + + while (lines.length < lineBudget) { + const insertionIndex = Math.max(2, lines.length - 3); + lines.splice(insertionIndex, 0, randomLine(random, 6, 18)); + } + + return lines.slice(0, lineBudget).join("\n"); +} + +function randomChangedFiles(random: () => number, count: number): string[] { + return Array.from({ length: count }, (_, index) => { + const segments = Array.from({ length: randomInt(random, 1, 4) }, () => + randomWord(random, 3, 12), + ); + return `apps/${segments.join("/")}/${randomWord(random, 4, 12)}-${index + 1}.ts`; + }); +} + +function asMessageId(value: string): MessageId { + return value as MessageId; +} + +function asTurnId(value: string): TurnId { + return value as TurnId; +} + +function asProposedPlanId(value: string): OrchestrationProposedPlanId { + return value as OrchestrationProposedPlanId; +} + +export function makeTimelineHeightAttachments(count: number): ChatAttachment[] { + return Array.from({ length: count }, (_, index) => ({ + type: "image", + id: `attachment-${index + 1}`, + name: `attachment-${index + 1}.png`, + mimeType: "image/png", + sizeBytes: (index + 1) * 1_024, + previewUrl: `data:image/svg+xml;utf8,${encodeURIComponent(ATTACHMENT_SVG)}`, + })); +} + +function createTimestamp(caseIndex: number, offsetSeconds: number): string { + return new Date(Date.UTC(2026, 2, 16, 12, caseIndex, offsetSeconds)).toISOString(); +} + +function createMessage(input: { + id: string; + role: ChatMessage["role"]; + text: string; + createdAt: string; + attachments?: ChatAttachment[]; +}): ChatMessage { + return { + id: asMessageId(input.id), + role: input.role, + text: input.text, + createdAt: input.createdAt, + streaming: false, + ...(input.attachments ? { attachments: input.attachments } : {}), + }; +} + +function createProposedPlan(input: { + id: string; + createdAt: string; + planMarkdown: string; +}): ProposedPlan { + return { + id: asProposedPlanId(input.id), + turnId: asTurnId(`${input.id}-turn`), + planMarkdown: input.planMarkdown, + createdAt: input.createdAt, + updatedAt: input.createdAt, + }; +} + +function createWorkEntry( + random: () => number, + caseIndex: number, + entryIndex: number, +): WorkLogEntry { + const id = `case-${caseIndex}-work-entry-${entryIndex + 1}`; + const createdAt = createTimestamp(caseIndex, 8 + entryIndex); + const label = randomLine(random, 3, 10); + const changedFiles = randomChangedFiles(random, randomInt(random, 1, 7)); + + if (entryIndex === 0) { + return { + id, + createdAt, + label, + tone: "thinking", + detail: randomLine(random, 5, 14), + }; + } + if (entryIndex === 1) { + return { + id, + createdAt, + label, + tone: "tool", + command: `tool-${randomWord(random, 4, 10)}`, + changedFiles, + }; + } + if (entryIndex === 2) { + return { + id, + createdAt, + label, + tone: "tool", + changedFiles, + }; + } + if (entryIndex === 3) { + return { + id, + createdAt, + label, + tone: "info", + detail: randomLine(random, 5, 14), + }; + } + if (entryIndex === 4) { + return { + id, + createdAt, + label, + tone: "error", + detail: randomLine(random, 5, 14), + }; + } + + return { + id, + createdAt, + label, + tone: (["thinking", "tool", "info", "error"] as const)[entryIndex % 4]!, + ...(random() < 0.5 ? { command: `cmd-${randomWord(random, 4, 10)}` } : {}), + ...(random() < 0.5 ? { detail: randomLine(random, 5, 14) } : {}), + ...(random() < 0.75 ? { changedFiles } : {}), + }; +} + +export function generateTimelineHeightThreadCases( + count = 100, +): GeneratedTimelineHeightThreadCase[] { + const random = createSeededRandom(0x5eed1234); + + return Array.from({ length: count }, (_, index) => { + const userText = randomPlainTextByLineCount(random); + const assistantText = randomPlainTextByLineCount(random); + const markdownBody = randomPlainTextByLineCount(random, 4, 39); + const markdownSummary = markdownBody.replace(/\s+/g, " ").trim(); + const markdownText = [ + `# ${markdownSummary.slice(0, 48) || "heading"}`, + "", + markdownBody, + "", + `- ${markdownSummary.slice(0, 32) || "first item"}`, + `- ${markdownSummary.slice(32, 64) || "second item"}`, + "", + `> ${markdownSummary.slice(0, 72) || "quoted note"}`, + "", + "```ts", + `export const sampleValue = "${markdownSummary.slice(0, 24) || "value"}";`, + "```", + ].join("\n"); + const attachmentCount = randomInt(random, 0, 4); + const diffSummaryFiles = randomChangedFiles(random, randomInt(random, 1, 12)).map((path) => ({ + path, + additions: randomInt(random, 0, 120), + deletions: randomInt(random, 0, 80), + })); + const workEntries = Array.from({ length: randomInt(random, 7, 14) }, (_, entryIndex) => + createWorkEntry(random, index, entryIndex), + ); + + const shortPlanMarkdown = randomMarkdownTextByLineCount(random, 4, 18); + const previewPlanMarkdown = [ + "# Plan", + "", + ...Array.from({ length: 10 }, () => `- ${randomLine(random, 6, 18)}`), + ].join("\n"); + const longPlanMarkdown = [ + previewPlanMarkdown, + "", + ...Array.from({ length: randomInt(random, 12, 24) }, () => `- ${randomLine(random, 8, 20)}`), + ].join("\n"); + const longerPlanMarkdown = [ + longPlanMarkdown, + "", + ...Array.from({ length: randomInt(random, 4, 10) }, () => `- ${randomLine(random, 8, 20)}`), + ].join("\n"); + + const ids = { + user: `case-${index}-user`, + assistantBaseline: `case-${index}-assistant-baseline`, + assistantMarkdownBaseline: `case-${index}-assistant-markdown-baseline`, + assistantStructured: `case-${index}-assistant-structured`, + assistantCompletion: `case-${index}-assistant-completion`, + assistantDiffSummary: `case-${index}-assistant-diff-summary`, + system: `case-${index}-system`, + workGroup: `case-${index}-work-group`, + shortPlan: `case-${index}-short-plan`, + longPlan: `case-${index}-long-plan`, + longerPlan: `case-${index}-longer-plan`, + working: "working-indicator-row", + }; + + const userMessage = createMessage({ + id: `${ids.user}-message`, + role: "user", + text: userText, + attachments: makeTimelineHeightAttachments(attachmentCount), + createdAt: createTimestamp(index, 1), + }); + const assistantBaselineMessage = createMessage({ + id: `${ids.assistantBaseline}-message`, + role: "assistant", + text: assistantText, + createdAt: createTimestamp(index, 2), + }); + const assistantMarkdownBaselineMessage = createMessage({ + id: `${ids.assistantMarkdownBaseline}-message`, + role: "assistant", + text: markdownBody, + createdAt: createTimestamp(index, 3), + }); + const assistantStructuredMessage = createMessage({ + id: `${ids.assistantStructured}-message`, + role: "assistant", + text: markdownText, + createdAt: createTimestamp(index, 4), + }); + const assistantCompletionMessage = createMessage({ + id: `${ids.assistantCompletion}-message`, + role: "assistant", + text: assistantText, + createdAt: createTimestamp(index, 5), + }); + const assistantDiffSummaryMessage = createMessage({ + id: `${ids.assistantDiffSummary}-message`, + role: "assistant", + text: assistantText, + createdAt: createTimestamp(index, 6), + }); + const systemMessage = createMessage({ + id: `${ids.system}-message`, + role: "system", + text: assistantText, + createdAt: createTimestamp(index, 7), + }); + + return { + index, + ids, + thread: { + timelineEntries: [ + { + id: ids.user, + kind: "message", + createdAt: userMessage.createdAt, + message: userMessage, + }, + { + id: ids.assistantBaseline, + kind: "message", + createdAt: assistantBaselineMessage.createdAt, + message: assistantBaselineMessage, + }, + { + id: ids.assistantMarkdownBaseline, + kind: "message", + createdAt: assistantMarkdownBaselineMessage.createdAt, + message: assistantMarkdownBaselineMessage, + }, + { + id: ids.assistantStructured, + kind: "message", + createdAt: assistantStructuredMessage.createdAt, + message: assistantStructuredMessage, + }, + { + id: ids.assistantCompletion, + kind: "message", + createdAt: assistantCompletionMessage.createdAt, + message: assistantCompletionMessage, + }, + { + id: ids.assistantDiffSummary, + kind: "message", + createdAt: assistantDiffSummaryMessage.createdAt, + message: assistantDiffSummaryMessage, + }, + { + id: ids.system, + kind: "message", + createdAt: systemMessage.createdAt, + message: systemMessage, + }, + ...workEntries.map((entry, entryIndex) => ({ + id: entryIndex === 0 ? ids.workGroup : `case-${index}-work-${entryIndex + 1}`, + kind: "work" as const, + createdAt: entry.createdAt, + entry, + })), + { + id: ids.shortPlan, + kind: "proposed-plan", + createdAt: createTimestamp(index, 30), + proposedPlan: createProposedPlan({ + id: `${ids.shortPlan}-plan`, + createdAt: createTimestamp(index, 30), + planMarkdown: shortPlanMarkdown, + }), + }, + { + id: ids.longPlan, + kind: "proposed-plan", + createdAt: createTimestamp(index, 31), + proposedPlan: createProposedPlan({ + id: `${ids.longPlan}-plan`, + createdAt: createTimestamp(index, 31), + planMarkdown: longPlanMarkdown, + }), + }, + { + id: ids.longerPlan, + kind: "proposed-plan", + createdAt: createTimestamp(index, 32), + proposedPlan: createProposedPlan({ + id: `${ids.longerPlan}-plan`, + createdAt: createTimestamp(index, 32), + planMarkdown: longerPlanMarkdown, + }), + }, + ], + completionDividerBeforeEntryId: ids.assistantCompletion, + turnDiffSummaryByAssistantMessageId: new Map([ + [ + assistantDiffSummaryMessage.id, + { + turnId: asTurnId(`case-${index}-turn`), + completedAt: assistantDiffSummaryMessage.createdAt, + files: diffSummaryFiles, + assistantMessageId: assistantDiffSummaryMessage.id, + }, + ], + ]), + isWorking: true, + }, + }; + }); +} + +export function buildGeneratedTimelineHeightRows( + generatedCase: GeneratedTimelineHeightThreadCase, + options: { expandedWorkGroupIds?: ReadonlyArray } = {}, +): GeneratedTimelineHeightResolvedRow[] { + const expandedWorkGroupIds = new Set(options.expandedWorkGroupIds ?? []); + return resolveMessagesTimelineRows({ + timelineEntries: generatedCase.thread.timelineEntries, + completionDividerBeforeEntryId: generatedCase.thread.completionDividerBeforeEntryId, + turnDiffSummaryByAssistantMessageId: generatedCase.thread.turnDiffSummaryByAssistantMessageId, + isWorking: generatedCase.thread.isWorking, + activeTurnStartedAt: generatedCase.thread.isWorking + ? (generatedCase.thread.timelineEntries.at(-1)?.createdAt ?? null) + : null, + }).map((row) => { + if (row.kind === "work") { + return { + id: row.id, + input: { + kind: "work", + groupedEntries: row.groupedEntries, + expanded: expandedWorkGroupIds.has(row.id), + }, + }; + } + if (row.kind === "proposed-plan") { + return { + id: row.id, + input: { + kind: "proposed-plan", + proposedPlan: row.proposedPlan, + }, + }; + } + if (row.kind === "working") { + return { + id: row.id, + input: { kind: "working" }, + }; + } + return { + id: row.id, + input: { + kind: "message", + message: row.message, + showCompletionDivider: row.showCompletionDivider, + diffSummary: row.assistantDiffSummary ? { files: row.assistantDiffSummary.files } : null, + }, + }; + }); +} + +function findTimelineEntryById( + generatedCase: GeneratedTimelineHeightThreadCase, + id: string, +): TimelineEntry { + const entry = generatedCase.thread.timelineEntries.find((candidate) => candidate.id === id); + if (!entry) { + throw new Error(`Unable to locate generated timeline entry ${id}`); + } + return entry; +} + +function findMessageEntryById( + generatedCase: GeneratedTimelineHeightThreadCase, + id: string, +): Extract { + const entry = findTimelineEntryById(generatedCase, id); + if (entry.kind !== "message") { + throw new Error(`Generated timeline entry ${id} is not a message`); + } + return entry; +} + +function findPlanEntryById( + generatedCase: GeneratedTimelineHeightThreadCase, + id: string, +): Extract { + const entry = findTimelineEntryById(generatedCase, id); + if (entry.kind !== "proposed-plan") { + throw new Error(`Generated timeline entry ${id} is not a proposed plan`); + } + return entry; +} + +function findDiffSummary( + generatedCase: GeneratedTimelineHeightThreadCase, + assistantMessageId: MessageId, +): TurnDiffSummary { + const summary = generatedCase.thread.turnDiffSummaryByAssistantMessageId.get(assistantMessageId); + if (!summary) { + throw new Error(`Unable to locate generated diff summary for ${assistantMessageId}`); + } + return summary; +} + +export function generateTimelineHeightEdgeCases(): GeneratedTimelineHeightEdgeCase[] { + const baseCases = generateTimelineHeightThreadCases(6); + + const denseTableCase = baseCases[0]!; + findMessageEntryById(denseTableCase, denseTableCase.ids.assistantStructured).message.text = [ + "# Changed files overview", + "", + "| file | additions | deletions | status |", + "| --- | ---: | ---: | --- |", + ...Array.from({ length: 18 }, (_, index) => { + const suffix = index + 1; + return `| apps/web/src/components/section-${suffix}.tsx | ${suffix * 3} | ${suffix} | updated |`; + }), + ].join("\n"); + + const longFencedCodeCase = baseCases[1]!; + findMessageEntryById( + longFencedCodeCase, + longFencedCodeCase.ids.assistantStructured, + ).message.text = [ + "```ts", + ...Array.from( + { length: 28 }, + (_, index) => + `export const line${index + 1} = "stream-${index + 1}-${"x".repeat(16 + (index % 8))}";`, + ), + "```", + ].join("\n"); + + const deepDiffTreeCase = baseCases[2]!; + const deepDiffSummary = findDiffSummary( + deepDiffTreeCase, + findMessageEntryById(deepDiffTreeCase, deepDiffTreeCase.ids.assistantDiffSummary).message.id, + ); + deepDiffSummary.files = Array.from({ length: 18 }, (_, index) => ({ + path: `apps/web/src/features/deep/tree/level-${(index % 6) + 1}/branch-${index + 1}/file-${index + 1}.ts`, + additions: 4 + index, + deletions: index % 5, + })); + + const borderlinePlanCase = baseCases[3]!; + findPlanEntryById(borderlinePlanCase, borderlinePlanCase.ids.longPlan).proposedPlan.planMarkdown = + [ + "# Borderline collapsible plan", + "", + ...Array.from({ length: 20 }, (_, index) => `- Step ${index + 1}: ${"detail ".repeat(8)}`), + ].join("\n"); + + const attachmentFallbackCase = baseCases[4]!; + const fallbackUserEntry = findMessageEntryById( + attachmentFallbackCase, + attachmentFallbackCase.ids.user, + ); + for (const attachment of fallbackUserEntry.message.attachments ?? []) { + delete attachment.previewUrl; + } + + const streamingMessagesCase = baseCases[5]!; + const streamingAssistantEntry = findMessageEntryById( + streamingMessagesCase, + streamingMessagesCase.ids.assistantBaseline, + ); + streamingAssistantEntry.message.streaming = true; + streamingAssistantEntry.message.completedAt = undefined; + const streamingSystemEntry = findMessageEntryById( + streamingMessagesCase, + streamingMessagesCase.ids.system, + ); + streamingSystemEntry.message.streaming = true; + streamingSystemEntry.message.completedAt = undefined; + + return [ + { name: "dense-table-markdown", generatedCase: denseTableCase }, + { name: "long-fenced-code", generatedCase: longFencedCodeCase }, + { name: "deep-diff-tree", generatedCase: deepDiffTreeCase }, + { name: "borderline-collapsible-plan", generatedCase: borderlinePlanCase }, + { name: "attachment-fallback", generatedCase: attachmentFallbackCase }, + { name: "streaming-messages", generatedCase: streamingMessagesCase }, + ]; +} diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 73a21cd08..4eef2fcbd 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -1,6 +1,53 @@ import { describe, expect, it } from "vitest"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; +import { estimateTimelineMessageHeight, estimateTimelineRowHeight } from "./timelineHeight"; +import { + buildGeneratedTimelineHeightRows, + generateTimelineHeightEdgeCases, + generateTimelineHeightThreadCases, + makeTimelineHeightAttachments, + type GeneratedTimelineHeightResolvedRow, +} from "./timelineHeight.generated"; + +function countLines(text: string): number { + return text.split("\n").length; +} + +function getGeneratedRow( + rows: GeneratedTimelineHeightResolvedRow[], + id: string, +): GeneratedTimelineHeightResolvedRow["input"] { + const row = rows.find((candidate) => candidate.id === id); + expect(row, `missing generated row ${id}`).toBeDefined(); + return row!.input; +} + +function getGeneratedMessageRow( + rows: GeneratedTimelineHeightResolvedRow[], + id: string, +): Extract { + const row = getGeneratedRow(rows, id); + expect(row.kind, `generated row ${id} should be a message row`).toBe("message"); + return row as Extract; +} + +function getGeneratedWorkRow( + rows: GeneratedTimelineHeightResolvedRow[], + id: string, +): Extract { + const row = getGeneratedRow(rows, id); + expect(row.kind, `generated row ${id} should be a work row`).toBe("work"); + return row as Extract; +} + +function getGeneratedPlanRow( + rows: GeneratedTimelineHeightResolvedRow[], + id: string, +): Extract { + const row = getGeneratedRow(rows, id); + expect(row.kind, `generated row ${id} should be a proposed-plan row`).toBe("proposed-plan"); + return row as Extract; +} describe("estimateTimelineMessageHeight", () => { it("uses assistant sizing rules for assistant messages", () => { @@ -104,4 +151,673 @@ describe("estimateTimelineMessageHeight", () => { expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); }); + + it("keeps generated user message estimates monotonic across widths and attachment bands", () => { + for (const generatedCase of generateTimelineHeightThreadCases()) { + const generatedMessage = getGeneratedMessageRow( + buildGeneratedTimelineHeightRows(generatedCase), + generatedCase.ids.user, + ).message; + const message = { + role: "user" as const, + text: generatedMessage.text, + }; + const lineCount = countLines(message.text); + + const wideHeight = estimateTimelineMessageHeight(message, { timelineWidthPx: 768 }); + const narrowHeight = estimateTimelineMessageHeight(message, { timelineWidthPx: 320 }); + const ultraNarrowHeight = estimateTimelineMessageHeight(message, { timelineWidthPx: 120 }); + + expect( + lineCount, + `user case ${generatedCase.index} should stay within the generated range`, + ).toBeGreaterThanOrEqual(4); + expect( + lineCount, + `user case ${generatedCase.index} should stay within the generated range`, + ).toBeLessThanOrEqual(50); + + expect( + narrowHeight, + `user case ${generatedCase.index} should not shrink on a narrower width`, + ).toBeGreaterThanOrEqual(wideHeight); + expect( + ultraNarrowHeight, + `user case ${generatedCase.index} should not shrink on an ultra-narrow width`, + ).toBeGreaterThanOrEqual(narrowHeight); + + const noAttachmentHeight = estimateTimelineMessageHeight(message); + const oneAttachmentHeight = estimateTimelineMessageHeight({ + ...message, + attachments: makeTimelineHeightAttachments(1), + }); + const twoAttachmentHeight = estimateTimelineMessageHeight({ + ...message, + attachments: makeTimelineHeightAttachments(2), + }); + const threeAttachmentHeight = estimateTimelineMessageHeight({ + ...message, + attachments: makeTimelineHeightAttachments(3), + }); + const fourAttachmentHeight = estimateTimelineMessageHeight({ + ...message, + attachments: makeTimelineHeightAttachments(4), + }); + + expect( + oneAttachmentHeight - noAttachmentHeight, + `user case ${generatedCase.index} first attachment row delta`, + ).toBe(228); + expect( + twoAttachmentHeight, + `user case ${generatedCase.index} two attachments should stay in the first row`, + ).toBe(oneAttachmentHeight); + expect( + threeAttachmentHeight - oneAttachmentHeight, + `user case ${generatedCase.index} second attachment row delta`, + ).toBe(228); + expect( + fourAttachmentHeight, + `user case ${generatedCase.index} four attachments should stay in the second row`, + ).toBe(threeAttachmentHeight); + } + }); + + it("keeps generated assistant and system message estimates aligned and width-sensitive", () => { + for (const generatedCase of generateTimelineHeightThreadCases()) { + const rows = buildGeneratedTimelineHeightRows(generatedCase); + const assistantMessage = getGeneratedMessageRow( + rows, + generatedCase.ids.assistantBaseline, + ).message; + const systemMessage = getGeneratedMessageRow(rows, generatedCase.ids.system).message; + const lineCount = countLines(assistantMessage.text); + + const wideAssistantHeight = estimateTimelineMessageHeight(assistantMessage, { + timelineWidthPx: 768, + }); + const narrowAssistantHeight = estimateTimelineMessageHeight(assistantMessage, { + timelineWidthPx: 320, + }); + + expect( + lineCount, + `assistant case ${generatedCase.index} should stay within the generated range`, + ).toBeGreaterThanOrEqual(4); + expect( + lineCount, + `assistant case ${generatedCase.index} should stay within the generated range`, + ).toBeLessThanOrEqual(50); + expect( + narrowAssistantHeight, + `assistant case ${generatedCase.index} should not shrink on a narrower width`, + ).toBeGreaterThanOrEqual(wideAssistantHeight); + expect( + estimateTimelineMessageHeight(systemMessage), + `system case ${generatedCase.index} should match assistant sizing`, + ).toBe(estimateTimelineMessageHeight(assistantMessage)); + } + }); +}); + +describe("estimateTimelineRowHeight", () => { + it("keeps a plain assistant row at the baseline height", () => { + expect( + estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: "a".repeat(144), + }, + }), + ).toBe(122); + }); + + it("gives structured assistant markdown more height than plain prose", () => { + const plainHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: "Plain assistant prose ".repeat(12), + }, + }); + const structuredHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: [ + "# Heading", + "", + "Assistant prose for the same rough amount of text.", + "", + "- item one", + "- item two", + "1. item three", + "2. item four", + ].join("\n"), + }, + }); + + expect(structuredHeight).toBeGreaterThan(plainHeight); + }); + + it("treats fenced code blocks as taller than plain paragraphs", () => { + const paragraphHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: "Code explanation paragraph ".repeat(10), + }, + }); + const fencedCodeHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: [ + "```ts", + "export function multiply(left: number, right: number) {", + " const product = left * right;", + " if (product > 100) {", + " return Math.round(product / 10) * 10;", + " }", + " return product;", + "}", + "", + "export function format(value: number) {", + " return `value:${value}`;", + "}", + "```", + ].join("\n"), + }, + }); + + expect(fencedCodeHeight).toBeGreaterThan(paragraphHeight); + }); + + it("keeps assistant markdown width-sensitive", () => { + const row = { + kind: "message" as const, + message: { + role: "assistant" as const, + text: [ + "# Heading", + "", + "Quoted context that should wrap more on a narrow layout.", + "", + "> nested note", + "", + "| file | additions | deletions |", + "| --- | ---: | ---: |", + "| timelineHeight.ts | 12 | 4 |", + ].join("\n"), + }, + }; + + expect(estimateTimelineRowHeight(row, { timelineWidthPx: 320 })).toBeGreaterThan( + estimateTimelineRowHeight(row, { timelineWidthPx: 768 }), + ); + }); + + it("keeps system messages on the assistant sizing path", () => { + const systemHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "system", + text: "a".repeat(144), + }, + }); + const assistantHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: "a".repeat(144), + }, + }); + + expect(systemHeight).toBe(assistantHeight); + }); + + it("adds the completion divider height exactly once", () => { + const baselineHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: "completion divider baseline", + }, + }); + const dividerHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: "completion divider baseline", + }, + showCompletionDivider: true, + }); + + expect(dividerHeight - baselineHeight).toBe(48); + }); + + it("adds changed-files summary height for assistant rows", () => { + const baselineHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: "Changed files summary baseline", + }, + }); + const withDiffSummaryHeight = estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: "Changed files summary baseline", + }, + diffSummary: { + files: [ + { + path: "apps/web/src/components/chat/MessagesTimeline.tsx", + additions: 10, + deletions: 2, + }, + { path: "apps/web/src/components/timelineHeight.ts", additions: 18, deletions: 7 }, + { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 5, deletions: 1 }, + { path: "apps/web/src/lib/turnDiffTree.ts", additions: 3, deletions: 0 }, + ], + }, + }); + + expect(withDiffSummaryHeight).toBeGreaterThan(baselineHeight); + }); + + it("keeps tool-only work groups compact when there is no overflow", () => { + expect( + estimateTimelineRowHeight({ + kind: "work", + groupedEntries: [ + { label: "Step 1", tone: "tool" }, + { label: "Step 2", tone: "tool" }, + { label: "Step 3", tone: "tool" }, + ], + }), + ).toBe(132); + }); + + it("adds the work-group header for mixed-tone rows", () => { + expect( + estimateTimelineRowHeight({ + kind: "work", + groupedEntries: [ + { label: "Thinking", tone: "thinking" }, + { label: "Tool step", tone: "tool" }, + ], + }), + ).toBe(126); + }); + + it("uses only the visible entry window for collapsed overflow work groups", () => { + const overflowEntries = Array.from({ length: 8 }, (_, index) => ({ + label: `Overflow step ${index + 1}`, + tone: "tool" as const, + })); + + expect( + estimateTimelineRowHeight({ + kind: "work", + groupedEntries: overflowEntries, + }), + ).toBe(254); + }); + + it("includes all work entries once an overflow work group is expanded", () => { + const overflowEntries = Array.from({ length: 8 }, (_, index) => ({ + label: `Overflow step ${index + 1}`, + tone: "tool" as const, + })); + + expect( + estimateTimelineRowHeight({ + kind: "work", + groupedEntries: overflowEntries, + expanded: true, + }), + ).toBe(318); + }); + + it("adds chip rows for changed-file work entries that also show command or detail", () => { + expect( + estimateTimelineRowHeight({ + kind: "work", + groupedEntries: [ + { + label: "Updated changed files", + tone: "tool", + command: "apply_patch", + changedFiles: [ + "apps/web/src/components/chat/MessagesTimeline.tsx", + "apps/web/src/components/timelineHeight.ts", + "apps/web/src/components/timelineHeight.test.ts", + "apps/web/src/components/chat/MessagesTimeline.browser.tsx", + "apps/web/src/components/ChatView.browser.helpers.tsx", + ], + }, + ], + }), + ).toBe(134); + }); + + it("uses the full displayed markdown for short proposed plans", () => { + const shortPlanBaseline = estimateTimelineRowHeight({ + kind: "proposed-plan", + proposedPlan: { + planMarkdown: "# Plan\n\n- Short step one", + }, + }); + const shortPlanExpandedBody = estimateTimelineRowHeight({ + kind: "proposed-plan", + proposedPlan: { + planMarkdown: [ + "# Plan", + "", + "- Short step one", + "- Short step two", + "- Short step three", + "- Short step four", + "- Short step five", + "- Short step six", + ].join("\n"), + }, + }); + + expect(shortPlanExpandedBody).toBeGreaterThan(shortPlanBaseline); + }); + + it("uses collapsed preview logic for long proposed plans", () => { + const previewLines = Array.from({ length: 10 }, (_, index) => `- Preview line ${index + 1}`); + const longPlanWithoutTail = estimateTimelineRowHeight({ + kind: "proposed-plan", + proposedPlan: { + planMarkdown: [ + "# Plan", + "", + ...previewLines, + "", + "- tail filler 1", + "- tail filler 2", + "- tail filler 3", + "- tail filler 4", + "- tail filler 5", + "- tail filler 6", + "- tail filler 7", + "- tail filler 8", + "- tail filler 9", + "- tail filler 10", + "- tail filler 11", + ].join("\n"), + }, + }); + const longPlanWithExtraTail = estimateTimelineRowHeight({ + kind: "proposed-plan", + proposedPlan: { + planMarkdown: [ + "# Plan", + "", + ...previewLines, + "", + ...Array.from({ length: 30 }, (_, index) => `- Hidden detail ${index + 1}`), + ].join("\n"), + }, + }); + + expect(longPlanWithExtraTail).toBe(longPlanWithoutTail); + }); + + it("caps long collapsed proposed-plan preview height", () => { + expect( + estimateTimelineRowHeight({ + kind: "proposed-plan", + proposedPlan: { + planMarkdown: ["# Plan", "", ...Array.from({ length: 24 }, () => "x".repeat(500))].join( + "\n", + ), + }, + }), + ).toBe(562); + }); + + it("keeps the working row fixed", () => { + expect(estimateTimelineRowHeight({ kind: "working" })).toBe(40); + }); + + it("covers generated thread-ready rows across messages, work groups, and proposed plans", () => { + for (const generatedCase of generateTimelineHeightThreadCases()) { + const collapsedRows = buildGeneratedTimelineHeightRows(generatedCase); + const expandedRows = buildGeneratedTimelineHeightRows(generatedCase, { + expandedWorkGroupIds: [generatedCase.ids.workGroup], + }); + + const userRow = getGeneratedMessageRow(collapsedRows, generatedCase.ids.user); + const assistantBaselineRow = getGeneratedMessageRow( + collapsedRows, + generatedCase.ids.assistantBaseline, + ); + const assistantMarkdownBaselineRow = getGeneratedMessageRow( + collapsedRows, + generatedCase.ids.assistantMarkdownBaseline, + ); + const assistantStructuredRow = getGeneratedMessageRow( + collapsedRows, + generatedCase.ids.assistantStructured, + ); + const assistantCompletionRow = getGeneratedMessageRow( + collapsedRows, + generatedCase.ids.assistantCompletion, + ); + const assistantDiffSummaryRow = getGeneratedMessageRow( + collapsedRows, + generatedCase.ids.assistantDiffSummary, + ); + const systemRow = getGeneratedMessageRow(collapsedRows, generatedCase.ids.system); + const collapsedWorkRow = getGeneratedWorkRow(collapsedRows, generatedCase.ids.workGroup); + const expandedWorkRow = getGeneratedWorkRow(expandedRows, generatedCase.ids.workGroup); + const shortPlanRow = getGeneratedPlanRow(collapsedRows, generatedCase.ids.shortPlan); + const longPlanRow = getGeneratedPlanRow(collapsedRows, generatedCase.ids.longPlan); + const longerPlanRow = getGeneratedPlanRow(collapsedRows, generatedCase.ids.longerPlan); + const workingRow = getGeneratedRow(collapsedRows, generatedCase.ids.working); + + expect( + countLines(userRow.message.text), + `user thread case ${generatedCase.index} should keep long-form randomized content`, + ).toBeGreaterThanOrEqual(4); + expect( + countLines(userRow.message.text), + `user thread case ${generatedCase.index} should keep long-form randomized content`, + ).toBeLessThanOrEqual(50); + expect( + countLines(assistantBaselineRow.message.text), + `assistant thread case ${generatedCase.index} should keep long-form randomized content`, + ).toBeGreaterThanOrEqual(4); + expect( + countLines(assistantBaselineRow.message.text), + `assistant thread case ${generatedCase.index} should keep long-form randomized content`, + ).toBeLessThanOrEqual(50); + expect( + countLines(assistantMarkdownBaselineRow.message.text), + `markdown baseline case ${generatedCase.index} should keep long-form randomized content`, + ).toBeGreaterThanOrEqual(4); + expect( + countLines(assistantMarkdownBaselineRow.message.text), + `markdown baseline case ${generatedCase.index} should keep long-form randomized content`, + ).toBeLessThanOrEqual(50); + expect( + countLines(assistantStructuredRow.message.text), + `structured markdown case ${generatedCase.index} should keep long-form randomized content`, + ).toBeGreaterThanOrEqual(4); + expect( + countLines(assistantStructuredRow.message.text), + `structured markdown case ${generatedCase.index} should keep long-form randomized content`, + ).toBeLessThanOrEqual(50); + + expect( + estimateTimelineRowHeight(userRow), + `row message case ${generatedCase.index} should delegate user rows to message estimation`, + ).toBe(estimateTimelineMessageHeight(userRow.message)); + expect( + estimateTimelineRowHeight(systemRow), + `system row case ${generatedCase.index} should stay on the assistant sizing path`, + ).toBe(estimateTimelineRowHeight(assistantBaselineRow)); + + const assistantBaselineHeight = estimateTimelineRowHeight(assistantBaselineRow); + const assistantMarkdownBaselineHeight = estimateTimelineRowHeight( + assistantMarkdownBaselineRow, + ); + const assistantStructuredHeight = estimateTimelineRowHeight(assistantStructuredRow); + const assistantCompletionHeight = estimateTimelineRowHeight(assistantCompletionRow); + const assistantDiffSummaryHeight = estimateTimelineRowHeight(assistantDiffSummaryRow); + + expect( + assistantStructuredHeight, + `assistant row case ${generatedCase.index} structured markdown should not be shorter than the same body as plain text`, + ).toBeGreaterThanOrEqual(assistantMarkdownBaselineHeight); + expect( + assistantCompletionHeight - assistantBaselineHeight, + `assistant row case ${generatedCase.index} completion divider delta`, + ).toBe(48); + expect( + assistantDiffSummaryHeight, + `assistant row case ${generatedCase.index} diff summary should increase height`, + ).toBeGreaterThan(assistantBaselineHeight); + + const collapsedWorkHeight = estimateTimelineRowHeight(collapsedWorkRow); + const expandedWorkHeight = estimateTimelineRowHeight(expandedWorkRow); + + expect( + expandedWorkHeight, + `work row case ${generatedCase.index} expanded height should not be smaller than collapsed`, + ).toBeGreaterThanOrEqual(collapsedWorkHeight); + + const shortPlanHeight = estimateTimelineRowHeight(shortPlanRow); + const longPlanHeight = estimateTimelineRowHeight(longPlanRow); + const longerPlanHeight = estimateTimelineRowHeight(longerPlanRow); + + expect( + shortPlanHeight, + `plan case ${generatedCase.index} short plan should exceed the chrome-only baseline`, + ).toBeGreaterThan(110); + expect( + longerPlanHeight, + `plan case ${generatedCase.index} adding hidden tail content should keep the collapsed preview stable`, + ).toBe(longPlanHeight); + expect( + estimateTimelineRowHeight(workingRow), + `working row case ${generatedCase.index} should stay fixed`, + ).toBe(40); + } + }); + + it("covers named edge fixtures for tables, code, fallback attachments, streaming, and plans", () => { + const edgeCases = generateTimelineHeightEdgeCases(); + + const denseTableCase = edgeCases.find((candidate) => candidate.name === "dense-table-markdown"); + const longFencedCodeCase = edgeCases.find((candidate) => candidate.name === "long-fenced-code"); + const deepDiffTreeCase = edgeCases.find((candidate) => candidate.name === "deep-diff-tree"); + const borderlinePlanCase = edgeCases.find( + (candidate) => candidate.name === "borderline-collapsible-plan", + ); + const attachmentFallbackCase = edgeCases.find( + (candidate) => candidate.name === "attachment-fallback", + ); + const streamingCase = edgeCases.find((candidate) => candidate.name === "streaming-messages"); + + expect(denseTableCase).toBeDefined(); + expect(longFencedCodeCase).toBeDefined(); + expect(deepDiffTreeCase).toBeDefined(); + expect(borderlinePlanCase).toBeDefined(); + expect(attachmentFallbackCase).toBeDefined(); + expect(streamingCase).toBeDefined(); + + const denseTableRows = buildGeneratedTimelineHeightRows(denseTableCase!.generatedCase); + const denseStructuredRow = getGeneratedMessageRow( + denseTableRows, + denseTableCase!.generatedCase.ids.assistantStructured, + ); + expect(denseStructuredRow.message.text).toContain("| file | additions | deletions | status |"); + expect(estimateTimelineRowHeight(denseStructuredRow)).toBeGreaterThan(300); + + const fencedCodeRows = buildGeneratedTimelineHeightRows(longFencedCodeCase!.generatedCase); + const fencedStructuredRow = getGeneratedMessageRow( + fencedCodeRows, + longFencedCodeCase!.generatedCase.ids.assistantStructured, + ); + expect(fencedStructuredRow.message.text).toContain("```ts"); + expect(estimateTimelineRowHeight(fencedStructuredRow)).toBeGreaterThan(300); + + const deepDiffRows = buildGeneratedTimelineHeightRows(deepDiffTreeCase!.generatedCase); + const deepDiffRow = getGeneratedMessageRow( + deepDiffRows, + deepDiffTreeCase!.generatedCase.ids.assistantDiffSummary, + ); + expect(deepDiffRow.diffSummary?.files.length).toBeGreaterThanOrEqual(18); + expect(estimateTimelineRowHeight(deepDiffRow)).toBeGreaterThan( + estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: deepDiffRow.message.text, + }, + }), + ); + + const borderlinePlanRows = buildGeneratedTimelineHeightRows(borderlinePlanCase!.generatedCase); + const borderlinePlanRow = getGeneratedPlanRow( + borderlinePlanRows, + borderlinePlanCase!.generatedCase.ids.longPlan, + ); + expect(borderlinePlanRow.proposedPlan.planMarkdown.split("\n").length).toBe(22); + expect(estimateTimelineRowHeight(borderlinePlanRow)).toBeGreaterThan(200); + + const attachmentFallbackRows = buildGeneratedTimelineHeightRows( + attachmentFallbackCase!.generatedCase, + ); + const attachmentFallbackUserRow = getGeneratedMessageRow( + attachmentFallbackRows, + attachmentFallbackCase!.generatedCase.ids.user, + ); + const attachmentFallbackUserEntry = + attachmentFallbackCase!.generatedCase.thread.timelineEntries.find( + (entry) => entry.id === attachmentFallbackCase!.generatedCase.ids.user, + ); + expect( + attachmentFallbackUserEntry?.kind === "message" && + attachmentFallbackUserEntry.message.attachments?.every( + (attachment) => !attachment.previewUrl, + ), + ).toBe(true); + expect(estimateTimelineRowHeight(attachmentFallbackUserRow)).toBeGreaterThan(300); + + const streamingRows = buildGeneratedTimelineHeightRows(streamingCase!.generatedCase); + const streamingSystemRow = getGeneratedMessageRow( + streamingRows, + streamingCase!.generatedCase.ids.system, + ); + const streamingAssistantEntry = streamingCase!.generatedCase.thread.timelineEntries.find( + (entry) => entry.id === streamingCase!.generatedCase.ids.assistantBaseline, + ); + const streamingSystemEntry = streamingCase!.generatedCase.thread.timelineEntries.find( + (entry) => entry.id === streamingCase!.generatedCase.ids.system, + ); + expect( + streamingAssistantEntry?.kind === "message" && streamingAssistantEntry.message.streaming, + ).toBe(true); + expect(streamingSystemEntry?.kind === "message" && streamingSystemEntry.message.streaming).toBe( + true, + ); + expect(estimateTimelineRowHeight(streamingSystemRow)).toBe( + estimateTimelineRowHeight({ + kind: "message", + message: { + role: "assistant", + text: streamingSystemRow.message.text, + }, + }), + ); + }); }); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 78a5f6539..dd9c88777 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,8 +1,26 @@ +import { + buildCollapsedProposedPlanPreviewMarkdown, + stripDisplayedPlanMarkdown, +} from "../proposedPlan"; +import { buildTurnDiffTree, type TurnDiffTreeNode } from "../lib/turnDiffTree"; + const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; const USER_CHARS_PER_LINE_FALLBACK = 56; const LINE_HEIGHT_PX = 22; const ASSISTANT_BASE_HEIGHT_PX = 78; const USER_BASE_HEIGHT_PX = 96; +const WORK_BASE_HEIGHT_PX = 36; +const WORK_HEADER_HEIGHT_PX = 26; +const WORK_ENTRY_HEIGHT_PX = 32; +const WORK_ENTRY_CHANGED_FILES_ROW_HEIGHT_PX = 22; +const WORK_MAX_VISIBLE_ENTRIES = 6; +const WORKING_ROW_HEIGHT_PX = 40; +const ASSISTANT_COMPLETION_DIVIDER_HEIGHT_PX = 48; +const ASSISTANT_DIFF_SUMMARY_BASE_HEIGHT_PX = 74; +const ASSISTANT_DIFF_TREE_NODE_HEIGHT_PX = 24; +const PROPOSED_PLAN_BASE_HEIGHT_PX = 94; +const PROPOSED_PLAN_COLLAPSED_CONTROLS_HEIGHT_PX = 52; +const PROPOSED_PLAN_COLLAPSED_PREVIEW_MAX_HEIGHT_PX = 416; const ATTACHMENTS_PER_ROW = 2; // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; @@ -24,6 +42,43 @@ interface TimelineHeightEstimateLayout { timelineWidthPx: number | null; } +interface TimelineWorkEntryHeightInput { + label: string; + tone: "thinking" | "tool" | "info" | "error"; + detail?: string; + command?: string; + changedFiles?: ReadonlyArray; + toolTitle?: string; +} + +interface TimelineDiffSummaryHeightInput { + files: ReadonlyArray<{ + path: string; + additions?: number | undefined; + deletions?: number | undefined; + }>; +} + +export type TimelineRowHeightInput = + | { + kind: "message"; + message: TimelineMessageHeightInput; + showCompletionDivider?: boolean; + diffSummary?: TimelineDiffSummaryHeightInput | null; + } + | { + kind: "work"; + groupedEntries: ReadonlyArray; + expanded?: boolean; + } + | { + kind: "proposed-plan"; + proposedPlan: { + planMarkdown: string; + }; + } + | { kind: "working" }; + function estimateWrappedLineCount(text: string, charsPerLine: number): number { if (text.length === 0) return 1; @@ -67,24 +122,305 @@ export function estimateTimelineMessageHeight( message: TimelineMessageHeightInput, layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, ): number { - if (message.role === "assistant") { - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; - } - if (message.role === "user") { - const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); 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 * LINE_HEIGHT_PX + attachmentHeight; + return estimateTextHeight({ + text: message.text, + charsPerLine: estimateCharsPerLineForUser(layout.timelineWidthPx), + baseHeightPx: USER_BASE_HEIGHT_PX, + extraHeightPx: attachmentHeight, + }); + } + + return estimateTextHeight({ + text: message.text, + charsPerLine: estimateCharsPerLineForAssistant(layout.timelineWidthPx), + baseHeightPx: ASSISTANT_BASE_HEIGHT_PX, + }); +} + +export function estimateTimelineRowHeight( + row: TimelineRowHeightInput, + layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, +): number { + switch (row.kind) { + case "working": + return WORKING_ROW_HEIGHT_PX; + case "work": + return estimateTimelineWorkRowHeight(row); + case "proposed-plan": + return estimateTimelineProposedPlanHeight(row.proposedPlan.planMarkdown, layout); + case "message": + if (row.message.role === "user") { + return estimateTimelineMessageHeight(row.message, layout); + } + + return ( + estimateAssistantMessageHeight(row.message.text, layout) + + (row.showCompletionDivider ? ASSISTANT_COMPLETION_DIVIDER_HEIGHT_PX : 0) + + (row.diffSummary ? estimateAssistantDiffSummaryHeight(row.diffSummary.files) : 0) + ); } +} - // `system` messages are not rendered in the chat timeline, but keep a stable - // explicit branch in case they are present in timeline data. - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; +function estimateAssistantMessageHeight( + text: string, + layout: TimelineHeightEstimateLayout, +): number { + return estimateTextHeight({ + text: normalizeAssistantTextForWrapping(text), + charsPerLine: estimateCharsPerLineForAssistant(layout.timelineWidthPx), + baseHeightPx: ASSISTANT_BASE_HEIGHT_PX, + extraHeightPx: estimateAssistantMarkdownStructureBonusPx(text), + }); +} + +function normalizeAssistantTextForWrapping(text: string): string { + if (text.length === 0) { + return text; + } + + const normalizedLines: string[] = []; + let paragraphBuffer: string[] = []; + let insideFence = false; + + const flushParagraph = () => { + if (paragraphBuffer.length === 0) { + return; + } + normalizedLines.push(paragraphBuffer.join(" ")); + paragraphBuffer = []; + }; + + for (const line of text.split("\n")) { + const trimmed = line.trim(); + const fenceBoundary = /^(```|~~~)/.test(trimmed); + const tableSeparator = isMarkdownTableSeparatorRow(trimmed); + const normalizedLine = normalizeAssistantMarkdownLine(trimmed, { + insideFence, + tableSeparator, + }); + const keepsOwnLine = + trimmed.length === 0 || + insideFence || + fenceBoundary || + /^#{1,6}\s+/.test(trimmed) || + /^([-*+]\s+|\d+[.)]\s+)/.test(trimmed) || + /^>\s?/.test(trimmed) || + looksLikeMarkdownTableRow(trimmed); + + if (fenceBoundary) { + flushParagraph(); + } else if (keepsOwnLine) { + flushParagraph(); + if (normalizedLine.length > 0) { + normalizedLines.push(normalizedLine); + } + } else { + paragraphBuffer.push(normalizedLine); + } + + if (fenceBoundary) { + insideFence = !insideFence; + } + } + + flushParagraph(); + return normalizedLines.join("\n"); +} + +function normalizeAssistantMarkdownLine( + line: string, + options: { insideFence: boolean; tableSeparator: boolean }, +): string { + if (line.length === 0) { + return line; + } + if (options.tableSeparator) { + return ""; + } + if (options.insideFence) { + return line; + } + if (looksLikeMarkdownTableRow(line)) { + return line + .split("|") + .map((cell) => cell.trim()) + .filter((cell) => cell.length > 0) + .join(" "); + } + return line + .replace(/^#{1,6}\s+/, "") + .replace(/^([-*+]\s+|\d+[.)]\s+)/, "") + .replace(/^>\s?/, ""); +} + +function estimateAssistantMarkdownStructureBonusPx(text: string): number { + if (text.trim().length === 0) { + return 0; + } + + const lines = text.split("\n"); + let headingCount = 0; + let listItemCount = 0; + let blockquoteLineCount = 0; + let tableLineCount = 0; + let fencedCodeBlockCount = 0; + let insideFence = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (/^(```|~~~)/.test(trimmed)) { + if (insideFence) { + fencedCodeBlockCount += 1; + } + insideFence = !insideFence; + continue; + } + + if (insideFence) { + continue; + } + + if (trimmed.length === 0) { + continue; + } + if (/^#{1,6}\s+/.test(trimmed)) { + headingCount += 1; + } + if (/^([-*+]\s+|\d+[.)]\s+)/.test(trimmed)) { + listItemCount += 1; + } + if (/^>\s?/.test(trimmed)) { + blockquoteLineCount += 1; + } + if (looksLikeMarkdownTableRow(trimmed)) { + tableLineCount += 1; + } + } + + if (insideFence) { + fencedCodeBlockCount += 1; + } + + return ( + headingCount * 6 + + listItemCount * 2 + + blockquoteLineCount * 4 + + tableLineCount * 8 + + fencedCodeBlockCount * 44 + ); +} + +function looksLikeMarkdownTableRow(line: string): boolean { + return line.includes("|") && line.split("|").length - 1 >= 2; +} + +function isMarkdownTableSeparatorRow(line: string): boolean { + return /^[\s|:-]+$/.test(line) && line.includes("-"); +} + +function estimateAssistantDiffSummaryHeight( + files: TimelineDiffSummaryHeightInput["files"], +): number { + if (files.length === 0) { + return 0; + } + + const visibleNodeCount = countVisibleTreeNodes(buildTurnDiffTree(files)); + return ( + ASSISTANT_DIFF_SUMMARY_BASE_HEIGHT_PX + visibleNodeCount * ASSISTANT_DIFF_TREE_NODE_HEIGHT_PX + ); +} + +function countVisibleTreeNodes(nodes: ReadonlyArray): number { + let count = 0; + for (const node of nodes) { + count += 1; + if (node.kind === "directory") { + count += countVisibleTreeNodes(node.children); + } + } + return count; +} + +function estimateTimelineWorkRowHeight( + row: Extract, +): number { + const groupedEntries = row.groupedEntries; + const visibleEntries = resolveVisibleWorkEntries(groupedEntries, row.expanded ?? false); + const hasOverflow = groupedEntries.length > WORK_MAX_VISIBLE_ENTRIES; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const showHeader = hasOverflow || !onlyToolEntries; + + let height = WORK_BASE_HEIGHT_PX; + if (showHeader) { + height += WORK_HEADER_HEIGHT_PX; + } + + for (const entry of visibleEntries) { + height += estimateTimelineWorkEntryHeight(entry); + } + + return height; +} + +function resolveVisibleWorkEntries( + groupedEntries: ReadonlyArray, + expanded: boolean, +): ReadonlyArray { + if (groupedEntries.length <= WORK_MAX_VISIBLE_ENTRIES || expanded) { + return groupedEntries; + } + return groupedEntries.slice(-WORK_MAX_VISIBLE_ENTRIES); +} + +function estimateTimelineWorkEntryHeight(entry: TimelineWorkEntryHeightInput): number { + const hasChangedFiles = (entry.changedFiles?.length ?? 0) > 0; + const previewIsChangedFiles = hasChangedFiles && !entry.command && !entry.detail; + if (!hasChangedFiles || previewIsChangedFiles) { + return WORK_ENTRY_HEIGHT_PX; + } + + const visibleChipCount = Math.min(entry.changedFiles?.length ?? 0, 4); + const extraIndicatorCount = (entry.changedFiles?.length ?? 0) > 4 ? 1 : 0; + const chipRows = Math.ceil((visibleChipCount + extraIndicatorCount) / 2); + return WORK_ENTRY_HEIGHT_PX + chipRows * WORK_ENTRY_CHANGED_FILES_ROW_HEIGHT_PX; +} + +function estimateTimelineProposedPlanHeight( + planMarkdown: string, + layout: TimelineHeightEstimateLayout, +): number { + const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); + const collapsible = planMarkdown.length > 900 || planMarkdown.split("\n").length > 20; + const markdownToEstimate = collapsible + ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) + : displayedPlanMarkdown; + const markdownHeight = estimateAssistantMessageHeight(markdownToEstimate, layout); + + if (!collapsible) { + return PROPOSED_PLAN_BASE_HEIGHT_PX + markdownHeight; + } + + return ( + PROPOSED_PLAN_BASE_HEIGHT_PX + + Math.min(markdownHeight, PROPOSED_PLAN_COLLAPSED_PREVIEW_MAX_HEIGHT_PX) + + PROPOSED_PLAN_COLLAPSED_CONTROLS_HEIGHT_PX + ); +} + +function estimateTextHeight(input: { + text: string; + charsPerLine: number; + baseHeightPx: number; + extraHeightPx?: number; +}): number { + return ( + input.baseHeightPx + + estimateWrappedLineCount(input.text, input.charsPerLine) * LINE_HEIGHT_PX + + (input.extraHeightPx ?? 0) + ); } diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index e7e240cf2..121ae9891 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -1,18 +1,57 @@ import { ThreadId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it } from "vitest"; - -import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; +const originalLocalStorage = globalThis.localStorage; +let selectThreadTerminalState: typeof import("./terminalStateStore").selectThreadTerminalState; +let useTerminalStateStore: typeof import("./terminalStateStore").useTerminalStateStore; + +function createMemoryStorage(): Storage { + const storage = new Map(); + + return { + get length() { + return storage.size; + }, + clear() { + storage.clear(); + }, + getItem(key) { + return storage.get(key) ?? null; + }, + key(index) { + return Array.from(storage.keys())[index] ?? null; + }, + removeItem(key) { + storage.delete(key); + }, + setItem(key, value) { + storage.set(key, value); + }, + }; +} describe("terminalStateStore actions", () => { - beforeEach(() => { - if (typeof localStorage !== "undefined") { - localStorage.clear(); - } + beforeEach(async () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createMemoryStorage(), + }); + + localStorage.removeItem(TERMINAL_STATE_STORAGE_KEY); + vi.resetModules(); + ({ selectThreadTerminalState, useTerminalStateStore } = await import("./terminalStateStore")); useTerminalStateStore.setState({ terminalStateByThreadId: {} }); }); + afterEach(() => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: originalLocalStorage, + }); + }); + it("returns a closed default terminal state for unknown threads", () => { const terminalState = selectThreadTerminalState( useTerminalStateStore.getState().terminalStateByThreadId, diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index c67fdfbe9..b293fc3fe 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -15,9 +15,11 @@ export default mergeConfig( }, }, test: { + fileParallelism: false, include: [ "src/components/ChatView.browser.tsx", "src/components/KeybindingsToast.browser.tsx", + "src/components/chat/MessagesTimeline.browser.tsx", ], browser: { enabled: true,