From 969e28824f1cf739b4dd0ad2a4bb8cb6824f8c59 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sat, 14 Mar 2026 00:06:09 +0100 Subject: [PATCH 1/4] fix(web): reduce chat timeline scroll twitching --- apps/web/src/components/ChatView.browser.tsx | 90 +++++- .../src/components/chat/MessagesTimeline.tsx | 142 ++++++-- .../web/src/components/timelineHeight.test.ts | 94 +++++- apps/web/src/components/timelineHeight.ts | 302 ++++++++++++++++-- apps/web/src/lib/turnDiffTree.test.ts | 28 +- apps/web/src/lib/turnDiffTree.ts | 14 + 6 files changed, 620 insertions(+), 50 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..7f626ad15 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -72,6 +72,34 @@ const TEXT_VIEWPORT_MATRIX = [ { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; +const DESKTOP_HEIGHT_VIEWPORT_MATRIX = [ + DEFAULT_VIEWPORT, + { + name: "desktop-short", + width: 960, + height: 760, + textTolerancePx: 44, + attachmentTolerancePx: 56, + }, + { + name: "desktop-tall", + width: 960, + height: 1_400, + textTolerancePx: 44, + attachmentTolerancePx: 56, + }, +] as const satisfies readonly ViewportSpec[]; +const MOBILE_HEIGHT_VIEWPORT_MATRIX = [ + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, + { name: "mobile-short", width: 430, height: 700, textTolerancePx: 56, attachmentTolerancePx: 56 }, + { + name: "mobile-tall", + width: 430, + height: 1_150, + textTolerancePx: 56, + attachmentTolerancePx: 56, + }, +] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT, { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, @@ -158,7 +186,7 @@ function createSnapshotForTargetUser(options: { }): OrchestrationReadModel { const messages: Array = []; - for (let index = 0; index < 22; index += 1) { + for (let index = 0; index < 70; index += 1) { const isTarget = index === 3; const userId = `msg-user-${index}` as MessageId; const assistantId = `msg-assistant-${index}` as MessageId; @@ -851,6 +879,66 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(ratio).toBeLessThan(1.35); }); + it.each([ + { + name: "desktop", + viewports: DESKTOP_HEIGHT_VIEWPORT_MATRIX, + userText: "x".repeat(2_200), + tolerancePx: 2, + }, + { + name: "mobile", + viewports: MOBILE_HEIGHT_VIEWPORT_MATRIX, + userText: "x".repeat(1_600), + tolerancePx: 2, + }, + ])( + "keeps user row height stable when only $name viewport height changes", + async ({ viewports, userText, tolerancePx }) => { + const targetMessageId = `msg-user-target-height-${viewports[0].name}` as MessageId; + const mounted = await mountChatView({ + viewport: viewports[0], + snapshot: createSnapshotForTargetUser({ + targetMessageId, + targetText: userText, + }), + }); + + try { + const baseline = await mounted.measureUserRow(targetMessageId); + const baselineEstimatedHeightPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: baseline.timelineWidthMeasuredPx }, + ); + expect( + Math.abs(baseline.measuredRowHeightPx - baselineEstimatedHeightPx), + ).toBeLessThanOrEqual(viewports[0].textTolerancePx); + + for (const viewport of viewports.slice(1)) { + await mounted.setViewport(viewport); + const measurement = await mounted.measureUserRow(targetMessageId); + const estimatedHeightPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: measurement.timelineWidthMeasuredPx }, + ); + + expect(measurement.renderedInVirtualizedRegion).toBe(true); + expect( + Math.abs(measurement.timelineWidthMeasuredPx - baseline.timelineWidthMeasuredPx), + ).toBeLessThanOrEqual(1); + expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( + viewport.textTolerancePx, + ); + expect( + Math.abs(measurement.measuredRowHeightPx - baseline.measuredRowHeightPx), + ).toBeLessThanOrEqual(tolerancePx); + } + } finally { + await mounted.cleanup(); + } + }, + ); + it.each(ATTACHMENT_VIEWPORT_MATRIX)( "keeps user attachment estimate close at the $name viewport", async (viewport) => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e30801041..c1019d852 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,14 +1,20 @@ import { type MessageId, type TurnId } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { + defaultRangeExtractor, measureElement as measureVirtualElement, + type Range, type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; -import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; +import { + buildTurnDiffTree, + countVisibleTurnDiffTreeNodes, + summarizeTurnDiffStats, +} from "../../lib/turnDiffTree"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, @@ -26,7 +32,7 @@ import { } from "lucide-react"; import { Button } from "../ui/button"; import { clamp } from "effect/Number"; -import { estimateTimelineMessageHeight } from "../timelineHeight"; +import { estimateTimelineMessageHeight, estimateTimelineWorkGroupHeight } from "../timelineHeight"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; @@ -39,6 +45,9 @@ import { formatTimestamp } from "../../timestampFormat"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; +const TIMELINE_BASE_OVERSCAN_ROWS = 8; +const TIMELINE_BACKWARD_EXTRA_OVERSCAN_ROWS = 48; +const MIN_ROWS_TO_ENABLE_VIRTUALIZATION = 120; interface MessagesTimelineProps { hasMessages: boolean; @@ -218,38 +227,94 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); }, [activeTurnInProgress, activeTurnStartedAt, rows]); - const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { - minimum: 0, - maximum: rows.length, - }); + const shouldVirtualizeRows = rows.length > MIN_ROWS_TO_ENABLE_VIRTUALIZATION; + const virtualizedRowCount = shouldVirtualizeRows + ? clamp(firstUnvirtualizedRowIndex, { + minimum: 0, + maximum: rows.length, + }) + : 0; + const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< + Record + >({}); + const onToggleAllDirectories = useCallback((turnId: TurnId) => { + setAllDirectoriesExpandedByTurnId((current) => ({ + ...current, + [turnId]: !(current[turnId] ?? true), + })); + }, []); + const scrollDirectionRef = useRef<"forward" | "backward" | null>(null); + const rangeExtractor = useCallback((range: Range) => { + if (scrollDirectionRef.current !== "backward") { + return defaultRangeExtractor(range); + } + + const startIndex = Math.max( + 0, + range.startIndex - range.overscan - TIMELINE_BACKWARD_EXTRA_OVERSCAN_ROWS, + ); + const endIndex = Math.min(range.count - 1, range.endIndex + range.overscan); + const indexes: number[] = []; + for (let index = startIndex; index <= endIndex; index += 1) { + indexes.push(index); + } + return indexes; + }, []); const rowVirtualizer = useVirtualizer({ count: virtualizedRowCount, getScrollElement: () => scrollContainer, + onChange: (instance) => { + scrollDirectionRef.current = instance.scrollDirection; + }, // Use stable row ids so virtual measurements do not leak across thread switches. getItemKey: (index: number) => rows[index]?.id ?? index, + rangeExtractor, estimateSize: (index: number) => { const row = rows[index]; if (!row) return 96; - if (row.kind === "work") return 112; + if (row.kind === "work") { + return estimateTimelineWorkGroupHeight(row.groupedEntries, { + expanded: expandedWorkGroups[row.id] ?? false, + maxVisibleEntries: MAX_VISIBLE_WORK_LOG_ENTRIES, + timelineWidthPx, + }); + } if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); if (row.kind === "working") return 40; - return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); + const turnDiffSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); + return estimateTimelineRowHeight(row, { + timelineWidthPx, + turnDiffSummary, + allDirectoriesExpanded: + row.message.role === "assistant" && turnDiffSummary + ? (allDirectoriesExpandedByTurnId[turnDiffSummary.turnId] ?? true) + : true, + }); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, - overscan: 8, + overscan: TIMELINE_BASE_OVERSCAN_ROWS, }); useEffect(() => { if (timelineWidthPx === null) return; rowVirtualizer.measure(); }, [rowVirtualizer, timelineWidthPx]); + useEffect(() => { + rowVirtualizer.measure(); + }, [ + allDirectoriesExpandedByTurnId, + expandedWorkGroups, + rowVirtualizer, + rows, + turnDiffSummaryByAssistantMessageId, + ]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + return remainingDistance <= AUTO_SCROLL_BOTTOM_THRESHOLD_PX; }; return () => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; @@ -274,15 +339,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); - const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< - Record - >({}); - const onToggleAllDirectories = useCallback((turnId: TurnId) => { - setAllDirectoriesExpandedByTurnId((current) => ({ - ...current, - [turnId]: !(current[turnId] ?? true), - })); - }, []); const renderRowContent = (row: TimelineRow) => (
, + layout: { + timelineWidthPx: number | null; + turnDiffSummary: TurnDiffSummary | undefined; + allDirectoriesExpanded: boolean; + }, +): number { + let height = estimateTimelineMessageHeight(row.message, { + timelineWidthPx: layout.timelineWidthPx, + }); + if (row.message.role !== "assistant") { + return height; + } + + if (row.showCompletionDivider) { + height += 40; + } + if (layout.turnDiffSummary && layout.turnDiffSummary.files.length > 0) { + height += estimateChangedFilesSummaryHeight( + layout.turnDiffSummary.files, + layout.allDirectoriesExpanded, + ); + } + return height; +} + +function estimateChangedFilesSummaryHeight( + files: ReadonlyArray, + allDirectoriesExpanded: boolean, +): number { + const summaryChromeHeightPx = 64; + const rowHeightPx = 22; + const rowGapPx = 2; + if (files.length <= 0) { + return 0; + } + const visibleRowCount = countVisibleTurnDiffTreeNodes( + buildTurnDiffTree(files), + allDirectoriesExpanded, + ); + return ( + summaryChromeHeightPx + + visibleRowCount * rowHeightPx + + Math.max(visibleRowCount - 1, 0) * rowGapPx + ); +} + function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); const endedAtMs = Date.parse(endIso); diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 73a21cd08..459975409 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { estimateTimelineMessageHeight } from "./timelineHeight"; +import { estimateTimelineMessageHeight, estimateTimelineWorkGroupHeight } from "./timelineHeight"; describe("estimateTimelineMessageHeight", () => { it("uses assistant sizing rules for assistant messages", () => { @@ -28,7 +28,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }], }), - ).toBe(346); + ).toBe(323); expect( estimateTimelineMessageHeight({ @@ -36,7 +36,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }], }), - ).toBe(346); + ).toBe(323); }); it("adds a second attachment row for three or four user attachments", () => { @@ -46,7 +46,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], }), - ).toBe(574); + ).toBe(551); expect( estimateTimelineMessageHeight({ @@ -54,7 +54,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], }), - ).toBe(574); + ).toBe(551); }); it("does not cap long user message estimates", () => { @@ -63,7 +63,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: "a".repeat(56 * 120), }), - ).toBe(2736); + ).toBe(2735); }); it("counts explicit newlines for user message estimates", () => { @@ -72,7 +72,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: "first\nsecond\nthird", }), - ).toBe(162); + ).toBe(139); }); it("uses narrower width to increase user line wrapping", () => { @@ -81,8 +81,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(52), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(117); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(95); }); it("does not clamp user wrapping too aggressively on very narrow layouts", () => { @@ -91,8 +91,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(20), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(161); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(95); }); it("uses narrower width to increase assistant line wrapping", () => { @@ -104,4 +104,76 @@ describe("estimateTimelineMessageHeight", () => { expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); }); + + it("adds markdown block spacing for structured assistant responses", () => { + const markdownMessage = { + role: "assistant" as const, + text: "# Heading\n\nParagraph one.\n\n- item one\n- item two\n\n> quoted line", + }; + const plainMessage = { + role: "assistant" as const, + text: "Heading Paragraph one. item one item two quoted line", + }; + + expect( + estimateTimelineMessageHeight(markdownMessage, { timelineWidthPx: 768 }), + ).toBeGreaterThan(estimateTimelineMessageHeight(plainMessage, { timelineWidthPx: 768 })); + }); + + it("adds extra height for assistant fenced code blocks", () => { + const codeMessage = { + role: "assistant" as const, + text: "```ts\nconst value = 1;\nconst next = value + 1;\n```", + }; + + expect(estimateTimelineMessageHeight(codeMessage, { timelineWidthPx: 768 })).toBe(160); + }); +}); + +describe("estimateTimelineWorkGroupHeight", () => { + it("accounts for visible entries, header chrome, and row spacing", () => { + expect( + estimateTimelineWorkGroupHeight( + Array.from({ length: 6 }, (_, index) => ({ + tone: "tool" as const, + command: `command-${index}`, + })), + { maxVisibleEntries: 6 }, + ), + ).toBe(208); + }); + + it("uses the collapsed visible-entry limit and header when the group overflows", () => { + expect( + estimateTimelineWorkGroupHeight( + Array.from({ length: 8 }, (_, index) => ({ + tone: "tool" as const, + command: `command-${index}`, + })), + { maxVisibleEntries: 6, expanded: false }, + ), + ).toBe(236); + }); + + it("accounts for wrapped changed-file chips based on width", () => { + const groupedEntries = [ + { + tone: "info" as const, + detail: "Updated files", + changedFiles: ["a.ts", "b.ts", "c.ts", "d.ts"], + }, + ]; + + expect( + estimateTimelineWorkGroupHeight(groupedEntries, { + timelineWidthPx: 320, + }), + ).toBe(178); + + expect( + estimateTimelineWorkGroupHeight(groupedEntries, { + timelineWidthPx: 768, + }), + ).toBe(112); + }); }); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 78a5f6539..95854b05f 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -2,7 +2,7 @@ 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 USER_BASE_HEIGHT_PX = 73; const ATTACHMENTS_PER_ROW = 2; // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; @@ -13,6 +13,24 @@ const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; +const USER_LONG_WRAP_BIAS_THRESHOLD_LINES = 40; +const MARKDOWN_BLOCK_GAP_PX = 10; +const MARKDOWN_CODE_BLOCK_BASE_HEIGHT_PX = 44; +const MARKDOWN_CODE_LINE_HEIGHT_PX = 19; +const MARKDOWN_HEADING_LINE_HEIGHT_PX = 28; +const MARKDOWN_LIST_ITEM_GAP_PX = 4; +const MARKDOWN_TABLE_ROW_HEIGHT_PX = 26; +const WORK_GROUP_ROW_BOTTOM_PADDING_PX = 16; +const WORK_GROUP_CARD_VERTICAL_PADDING_PX = 14; +const WORK_GROUP_HEADER_HEIGHT_PX = 20; +const WORK_GROUP_HEADER_TO_LIST_GAP_PX = 8; +const WORK_ENTRY_HEIGHT_PX = 28; +const WORK_ENTRY_STACK_GAP_PX = 2; +const WORK_ENTRY_CHANGED_FILES_TOP_MARGIN_PX = 4; +const WORK_ENTRY_CHANGED_FILES_ROW_HEIGHT_PX = 22; +const WORK_ENTRY_CHANGED_FILE_CHIP_WIDTH_PX = 168; +const WORK_ENTRY_CHANGED_FILES_MAX_VISIBLE = 4; +const WORK_ENTRY_CHANGED_FILES_WIDTH_PADDING_PX = 96; interface TimelineMessageHeightInput { role: "user" | "assistant" | "system"; @@ -21,7 +39,19 @@ interface TimelineMessageHeightInput { } interface TimelineHeightEstimateLayout { - timelineWidthPx: number | null; + timelineWidthPx?: number | null; +} + +interface TimelineWorkGroupHeightInput { + tone: "thinking" | "tool" | "info" | "error"; + detail?: string; + command?: string; + changedFiles?: ReadonlyArray; +} + +interface TimelineWorkGroupEstimateLayout extends TimelineHeightEstimateLayout { + expanded?: boolean; + maxVisibleEntries?: number; } function estimateWrappedLineCount(text: string, charsPerLine: number): number { @@ -43,48 +73,284 @@ function estimateWrappedLineCount(text: string, charsPerLine: number): number { return lines; } +function estimateWrappedLinesForTexts(texts: ReadonlyArray, charsPerLine: number): number { + return texts.reduce((total, text) => total + estimateWrappedLineCount(text, charsPerLine), 0); +} + function isFinitePositiveNumber(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; } +function estimateCharsPerLine( + availableWidthPx: number | null, + averageCharWidthPx: number, + minimumCharsPerLine: number, + fallbackCharsPerLine: number, +): number { + if (!isFinitePositiveNumber(availableWidthPx)) return fallbackCharsPerLine; + return Math.max(minimumCharsPerLine, Math.floor(availableWidthPx / averageCharWidthPx)); +} + function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { - if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; - const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; - const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); - return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); + const bubbleWidthPx = isFinitePositiveNumber(timelineWidthPx) + ? timelineWidthPx * USER_BUBBLE_WIDTH_RATIO + : null; + const textWidthPx = + bubbleWidthPx === null ? null : Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); + return estimateCharsPerLine( + textWidthPx, + USER_MONO_AVG_CHAR_WIDTH_PX, + MIN_USER_CHARS_PER_LINE, + USER_CHARS_PER_LINE_FALLBACK, + ); } function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { - if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; - const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); - return Math.max( + const textWidthPx = isFinitePositiveNumber(timelineWidthPx) + ? Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0) + : null; + return estimateCharsPerLine( + textWidthPx, + ASSISTANT_AVG_CHAR_WIDTH_PX, MIN_ASSISTANT_CHARS_PER_LINE, - Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), + ASSISTANT_CHARS_PER_LINE_FALLBACK, + ); +} + +function estimateChangedFileChipRows( + changedFileCount: number, + timelineWidthPx: number | null, +): number { + if (changedFileCount <= 0) return 0; + const availableWidthPx = isFinitePositiveNumber(timelineWidthPx) + ? Math.max(timelineWidthPx - WORK_ENTRY_CHANGED_FILES_WIDTH_PADDING_PX, 0) + : WORK_ENTRY_CHANGED_FILE_CHIP_WIDTH_PX * 2; + const chipsPerRow = Math.max( + 1, + Math.floor(availableWidthPx / WORK_ENTRY_CHANGED_FILE_CHIP_WIDTH_PX), ); + return Math.ceil(Math.min(changedFileCount, WORK_ENTRY_CHANGED_FILES_MAX_VISIBLE) / chipsPerRow); +} + +function estimateWorkEntryHeight( + entry: TimelineWorkGroupHeightInput, + timelineWidthPx: number | null, +): number { + let height = WORK_ENTRY_HEIGHT_PX; + const changedFileCount = entry.changedFiles?.length ?? 0; + const previewIsChangedFiles = changedFileCount > 0 && !entry.command && !entry.detail; + if (changedFileCount > 0 && !previewIsChangedFiles) { + height += + WORK_ENTRY_CHANGED_FILES_TOP_MARGIN_PX + + estimateChangedFileChipRows(changedFileCount, timelineWidthPx) * + WORK_ENTRY_CHANGED_FILES_ROW_HEIGHT_PX; + } + return height; +} + +function isMarkdownHeading(line: string): boolean { + return /^#{1,6}\s+/.test(line); +} + +function isMarkdownListItem(line: string): boolean { + return /^\s*(?:[-*+]\s+|\d+\.\s+)/.test(line); +} + +function isMarkdownBlockquote(line: string): boolean { + return /^\s*>\s?/.test(line); +} + +function isMarkdownTableLine(line: string): boolean { + return line.includes("|"); +} + +function isMarkdownTableSeparator(line: string): boolean { + return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line); +} + +function hasStructuredMarkdown(text: string): boolean { + return /(^|\n)(#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+|```|\|)|\n\s*\n/.test(text); +} + +function estimateAssistantMarkdownHeight(text: string, timelineWidthPx: number | null): number { + const charsPerLine = estimateCharsPerLineForAssistant(timelineWidthPx); + if (!hasStructuredMarkdown(text)) { + return ASSISTANT_BASE_HEIGHT_PX + estimateWrappedLineCount(text, charsPerLine) * LINE_HEIGHT_PX; + } + + const lines = text.split("\n"); + let totalHeight = ASSISTANT_BASE_HEIGHT_PX; + let lineIndex = 0; + let blockCount = 0; + + const startBlock = () => { + if (blockCount > 0) { + totalHeight += MARKDOWN_BLOCK_GAP_PX; + } + blockCount += 1; + }; + + while (lineIndex < lines.length) { + const rawLine = lines[lineIndex] ?? ""; + const trimmedLine = rawLine.trim(); + if (trimmedLine.length === 0) { + lineIndex += 1; + continue; + } + + if (trimmedLine.startsWith("```")) { + startBlock(); + let codeLineCount = 0; + lineIndex += 1; + while (lineIndex < lines.length && !(lines[lineIndex] ?? "").trim().startsWith("```")) { + codeLineCount += 1; + lineIndex += 1; + } + if (lineIndex < lines.length) { + lineIndex += 1; + } + totalHeight += + MARKDOWN_CODE_BLOCK_BASE_HEIGHT_PX + + Math.max(codeLineCount, 1) * MARKDOWN_CODE_LINE_HEIGHT_PX; + continue; + } + + if (isMarkdownHeading(trimmedLine)) { + startBlock(); + totalHeight += + estimateWrappedLineCount(trimmedLine.replace(/^#{1,6}\s+/, ""), charsPerLine) * + MARKDOWN_HEADING_LINE_HEIGHT_PX; + lineIndex += 1; + continue; + } + + if (isMarkdownBlockquote(trimmedLine)) { + startBlock(); + const quoteLines: string[] = []; + while (lineIndex < lines.length) { + const candidate = lines[lineIndex] ?? ""; + if (!isMarkdownBlockquote(candidate.trim())) break; + quoteLines.push(candidate.replace(/^\s*>\s?/, "")); + lineIndex += 1; + } + totalHeight += + estimateWrappedLinesForTexts(quoteLines, Math.max(charsPerLine - 3, 12)) * LINE_HEIGHT_PX + + 8; + continue; + } + + if ( + isMarkdownTableLine(rawLine) && + lineIndex + 1 < lines.length && + isMarkdownTableSeparator(lines[lineIndex + 1] ?? "") + ) { + startBlock(); + let rowCount = 0; + while (lineIndex < lines.length) { + const candidate = lines[lineIndex] ?? ""; + if (candidate.trim().length === 0 || !isMarkdownTableLine(candidate)) break; + rowCount += 1; + lineIndex += 1; + } + totalHeight += Math.max(rowCount, 2) * MARKDOWN_TABLE_ROW_HEIGHT_PX; + continue; + } + + if (isMarkdownListItem(trimmedLine)) { + startBlock(); + const listLines: string[] = []; + while (lineIndex < lines.length) { + const candidate = lines[lineIndex] ?? ""; + const candidateTrimmed = candidate.trim(); + if (candidateTrimmed.length === 0) break; + if (!isMarkdownListItem(candidateTrimmed) && !/^\s{2,}\S/.test(candidate)) break; + listLines.push(candidateTrimmed.replace(/^(?:[-*+]\s+|\d+\.\s+)\s?/, "")); + lineIndex += 1; + } + totalHeight += + estimateWrappedLinesForTexts(listLines, Math.max(charsPerLine - 3, 12)) * LINE_HEIGHT_PX + + Math.max(listLines.length - 1, 0) * MARKDOWN_LIST_ITEM_GAP_PX; + continue; + } + + startBlock(); + const paragraphLines: string[] = []; + while (lineIndex < lines.length) { + const candidate = lines[lineIndex] ?? ""; + const candidateTrimmed = candidate.trim(); + if (candidateTrimmed.length === 0) break; + if ( + candidateTrimmed.startsWith("```") || + isMarkdownHeading(candidateTrimmed) || + isMarkdownListItem(candidateTrimmed) || + isMarkdownBlockquote(candidateTrimmed) || + (isMarkdownTableLine(candidate) && + lineIndex + 1 < lines.length && + isMarkdownTableSeparator(lines[lineIndex + 1] ?? "")) + ) { + break; + } + paragraphLines.push(candidateTrimmed); + lineIndex += 1; + } + totalHeight += estimateWrappedLinesForTexts(paragraphLines, charsPerLine) * LINE_HEIGHT_PX; + } + + return totalHeight; } export function estimateTimelineMessageHeight( message: TimelineMessageHeightInput, layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, ): number { + const timelineWidthPx = layout.timelineWidthPx ?? null; if (message.role === "assistant") { - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; + return estimateAssistantMarkdownHeight(message.text, timelineWidthPx); } if (message.role === "user") { - const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + const charsPerLine = estimateCharsPerLineForUser(timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const wrapBiasLines = estimatedLines >= USER_LONG_WRAP_BIAS_THRESHOLD_LINES ? 1 : 0; 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 ( + USER_BASE_HEIGHT_PX + (estimatedLines + wrapBiasLines) * LINE_HEIGHT_PX + attachmentHeight + ); } // `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; + return estimateAssistantMarkdownHeight(message.text, timelineWidthPx); +} + +export function estimateTimelineWorkGroupHeight( + groupedEntries: ReadonlyArray, + layout: TimelineWorkGroupEstimateLayout = { timelineWidthPx: null }, +): number { + const timelineWidthPx = layout.timelineWidthPx ?? null; + if (groupedEntries.length === 0) { + return WORK_GROUP_ROW_BOTTOM_PADDING_PX + WORK_GROUP_CARD_VERTICAL_PADDING_PX; + } + + const maxVisibleEntries = layout.maxVisibleEntries ?? groupedEntries.length; + const isExpanded = layout.expanded ?? false; + const hasOverflow = groupedEntries.length > maxVisibleEntries; + const visibleEntries = + hasOverflow && !isExpanded ? groupedEntries.slice(-maxVisibleEntries) : groupedEntries; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const showHeader = hasOverflow || !onlyToolEntries; + const entryHeights = visibleEntries.reduce( + (totalHeight, entry) => totalHeight + estimateWorkEntryHeight(entry, timelineWidthPx), + 0, + ); + + return ( + WORK_GROUP_ROW_BOTTOM_PADDING_PX + + WORK_GROUP_CARD_VERTICAL_PADDING_PX + + entryHeights + + Math.max(visibleEntries.length - 1, 0) * WORK_ENTRY_STACK_GAP_PX + + (showHeader ? WORK_GROUP_HEADER_HEIGHT_PX + WORK_GROUP_HEADER_TO_LIST_GAP_PX : 0) + ); } diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index 6389fc3ee..471d77075 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; +import { + buildTurnDiffTree, + countVisibleTurnDiffTreeNodes, + summarizeTurnDiffStats, +} from "./turnDiffTree"; describe("summarizeTurnDiffStats", () => { it("sums only files with numeric additions/deletions", () => { @@ -166,3 +170,25 @@ describe("buildTurnDiffTree", () => { expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]); }); }); + +describe("countVisibleTurnDiffTreeNodes", () => { + it("counts only top-level nodes when directories are collapsed", () => { + const tree = buildTurnDiffTree([ + { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, + { path: "apps/web/src/App.tsx", additions: 4, deletions: 2 }, + { path: "README.md", additions: 1, deletions: 0 }, + ]); + + expect(countVisibleTurnDiffTreeNodes(tree, false)).toBe(2); + }); + + it("counts nested directory and file rows when directories are expanded", () => { + const tree = buildTurnDiffTree([ + { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, + { path: "apps/web/src/App.tsx", additions: 4, deletions: 2 }, + { path: "README.md", additions: 1, deletions: 0 }, + ]); + + expect(countVisibleTurnDiffTreeNodes(tree, true)).toBe(5); + }); +}); diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index cd9bfc831..a88a8d55b 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -170,3 +170,17 @@ export function buildTurnDiffTree(files: ReadonlyArray): Tur return toTreeNodes(root); } + +export function countVisibleTurnDiffTreeNodes( + nodes: ReadonlyArray, + allDirectoriesExpanded: boolean, +): number { + let visibleNodeCount = 0; + for (const node of nodes) { + visibleNodeCount += 1; + if (node.kind === "directory" && allDirectoriesExpanded) { + visibleNodeCount += countVisibleTurnDiffTreeNodes(node.children, allDirectoriesExpanded); + } + } + return visibleNodeCount; +} From 182a898902bc023ef4065793c8e6f45c4094b1d7 Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sat, 14 Mar 2026 00:10:41 +0100 Subject: [PATCH 2/4] test(web): trim extra timeline viewport coverage --- apps/web/src/components/ChatView.browser.tsx | 88 -------------------- 1 file changed, 88 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7f626ad15..4b937d5b5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -72,34 +72,6 @@ const TEXT_VIEWPORT_MATRIX = [ { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; -const DESKTOP_HEIGHT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { - name: "desktop-short", - width: 960, - height: 760, - textTolerancePx: 44, - attachmentTolerancePx: 56, - }, - { - name: "desktop-tall", - width: 960, - height: 1_400, - textTolerancePx: 44, - attachmentTolerancePx: 56, - }, -] as const satisfies readonly ViewportSpec[]; -const MOBILE_HEIGHT_VIEWPORT_MATRIX = [ - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "mobile-short", width: 430, height: 700, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { - name: "mobile-tall", - width: 430, - height: 1_150, - textTolerancePx: 56, - attachmentTolerancePx: 56, - }, -] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT, { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, @@ -879,66 +851,6 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(ratio).toBeLessThan(1.35); }); - it.each([ - { - name: "desktop", - viewports: DESKTOP_HEIGHT_VIEWPORT_MATRIX, - userText: "x".repeat(2_200), - tolerancePx: 2, - }, - { - name: "mobile", - viewports: MOBILE_HEIGHT_VIEWPORT_MATRIX, - userText: "x".repeat(1_600), - tolerancePx: 2, - }, - ])( - "keeps user row height stable when only $name viewport height changes", - async ({ viewports, userText, tolerancePx }) => { - const targetMessageId = `msg-user-target-height-${viewports[0].name}` as MessageId; - const mounted = await mountChatView({ - viewport: viewports[0], - snapshot: createSnapshotForTargetUser({ - targetMessageId, - targetText: userText, - }), - }); - - try { - const baseline = await mounted.measureUserRow(targetMessageId); - const baselineEstimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: baseline.timelineWidthMeasuredPx }, - ); - expect( - Math.abs(baseline.measuredRowHeightPx - baselineEstimatedHeightPx), - ).toBeLessThanOrEqual(viewports[0].textTolerancePx); - - for (const viewport of viewports.slice(1)) { - await mounted.setViewport(viewport); - const measurement = await mounted.measureUserRow(targetMessageId); - const estimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: measurement.timelineWidthMeasuredPx }, - ); - - expect(measurement.renderedInVirtualizedRegion).toBe(true); - expect( - Math.abs(measurement.timelineWidthMeasuredPx - baseline.timelineWidthMeasuredPx), - ).toBeLessThanOrEqual(1); - expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, - ); - expect( - Math.abs(measurement.measuredRowHeightPx - baseline.measuredRowHeightPx), - ).toBeLessThanOrEqual(tolerancePx); - } - } finally { - await mounted.cleanup(); - } - }, - ); - it.each(ATTACHMENT_VIEWPORT_MATRIX)( "keeps user attachment estimate close at the $name viewport", async (viewport) => { From 9808dc46c788df6dc25b46a96019d042127c417a Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sat, 14 Mar 2026 00:20:07 +0100 Subject: [PATCH 3/4] refactor(web): simplify timeline height estimation --- .../src/components/chat/MessagesTimeline.tsx | 24 --- .../web/src/components/timelineHeight.test.ts | 24 --- apps/web/src/components/timelineHeight.ts | 169 +----------------- 3 files changed, 6 insertions(+), 211 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c1019d852..0d03fb07d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,9 +1,7 @@ import { type MessageId, type TurnId } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { - defaultRangeExtractor, measureElement as measureVirtualElement, - type Range, type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; @@ -46,7 +44,6 @@ import { formatTimestamp } from "../../timestampFormat"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; const TIMELINE_BASE_OVERSCAN_ROWS = 8; -const TIMELINE_BACKWARD_EXTRA_OVERSCAN_ROWS = 48; const MIN_ROWS_TO_ENABLE_VIRTUALIZATION = 120; interface MessagesTimelineProps { @@ -243,33 +240,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [turnId]: !(current[turnId] ?? true), })); }, []); - const scrollDirectionRef = useRef<"forward" | "backward" | null>(null); - const rangeExtractor = useCallback((range: Range) => { - if (scrollDirectionRef.current !== "backward") { - return defaultRangeExtractor(range); - } - - const startIndex = Math.max( - 0, - range.startIndex - range.overscan - TIMELINE_BACKWARD_EXTRA_OVERSCAN_ROWS, - ); - const endIndex = Math.min(range.count - 1, range.endIndex + range.overscan); - const indexes: number[] = []; - for (let index = startIndex; index <= endIndex; index += 1) { - indexes.push(index); - } - return indexes; - }, []); const rowVirtualizer = useVirtualizer({ count: virtualizedRowCount, getScrollElement: () => scrollContainer, - onChange: (instance) => { - scrollDirectionRef.current = instance.scrollDirection; - }, // Use stable row ids so virtual measurements do not leak across thread switches. getItemKey: (index: number) => rows[index]?.id ?? index, - rangeExtractor, estimateSize: (index: number) => { const row = rows[index]; if (!row) return 96; diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 459975409..6d16657ef 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -104,30 +104,6 @@ describe("estimateTimelineMessageHeight", () => { expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); }); - - it("adds markdown block spacing for structured assistant responses", () => { - const markdownMessage = { - role: "assistant" as const, - text: "# Heading\n\nParagraph one.\n\n- item one\n- item two\n\n> quoted line", - }; - const plainMessage = { - role: "assistant" as const, - text: "Heading Paragraph one. item one item two quoted line", - }; - - expect( - estimateTimelineMessageHeight(markdownMessage, { timelineWidthPx: 768 }), - ).toBeGreaterThan(estimateTimelineMessageHeight(plainMessage, { timelineWidthPx: 768 })); - }); - - it("adds extra height for assistant fenced code blocks", () => { - const codeMessage = { - role: "assistant" as const, - text: "```ts\nconst value = 1;\nconst next = value + 1;\n```", - }; - - expect(estimateTimelineMessageHeight(codeMessage, { timelineWidthPx: 768 })).toBe(160); - }); }); describe("estimateTimelineWorkGroupHeight", () => { diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 95854b05f..af909ce51 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -14,12 +14,6 @@ const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; const USER_LONG_WRAP_BIAS_THRESHOLD_LINES = 40; -const MARKDOWN_BLOCK_GAP_PX = 10; -const MARKDOWN_CODE_BLOCK_BASE_HEIGHT_PX = 44; -const MARKDOWN_CODE_LINE_HEIGHT_PX = 19; -const MARKDOWN_HEADING_LINE_HEIGHT_PX = 28; -const MARKDOWN_LIST_ITEM_GAP_PX = 4; -const MARKDOWN_TABLE_ROW_HEIGHT_PX = 26; const WORK_GROUP_ROW_BOTTOM_PADDING_PX = 16; const WORK_GROUP_CARD_VERTICAL_PADDING_PX = 14; const WORK_GROUP_HEADER_HEIGHT_PX = 20; @@ -73,10 +67,6 @@ function estimateWrappedLineCount(text: string, charsPerLine: number): number { return lines; } -function estimateWrappedLinesForTexts(texts: ReadonlyArray, charsPerLine: number): number { - return texts.reduce((total, text) => total + estimateWrappedLineCount(text, charsPerLine), 0); -} - function isFinitePositiveNumber(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; } @@ -148,164 +138,15 @@ function estimateWorkEntryHeight( return height; } -function isMarkdownHeading(line: string): boolean { - return /^#{1,6}\s+/.test(line); -} - -function isMarkdownListItem(line: string): boolean { - return /^\s*(?:[-*+]\s+|\d+\.\s+)/.test(line); -} - -function isMarkdownBlockquote(line: string): boolean { - return /^\s*>\s?/.test(line); -} - -function isMarkdownTableLine(line: string): boolean { - return line.includes("|"); -} - -function isMarkdownTableSeparator(line: string): boolean { - return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line); -} - -function hasStructuredMarkdown(text: string): boolean { - return /(^|\n)(#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+|```|\|)|\n\s*\n/.test(text); -} - -function estimateAssistantMarkdownHeight(text: string, timelineWidthPx: number | null): number { - const charsPerLine = estimateCharsPerLineForAssistant(timelineWidthPx); - if (!hasStructuredMarkdown(text)) { - return ASSISTANT_BASE_HEIGHT_PX + estimateWrappedLineCount(text, charsPerLine) * LINE_HEIGHT_PX; - } - - const lines = text.split("\n"); - let totalHeight = ASSISTANT_BASE_HEIGHT_PX; - let lineIndex = 0; - let blockCount = 0; - - const startBlock = () => { - if (blockCount > 0) { - totalHeight += MARKDOWN_BLOCK_GAP_PX; - } - blockCount += 1; - }; - - while (lineIndex < lines.length) { - const rawLine = lines[lineIndex] ?? ""; - const trimmedLine = rawLine.trim(); - if (trimmedLine.length === 0) { - lineIndex += 1; - continue; - } - - if (trimmedLine.startsWith("```")) { - startBlock(); - let codeLineCount = 0; - lineIndex += 1; - while (lineIndex < lines.length && !(lines[lineIndex] ?? "").trim().startsWith("```")) { - codeLineCount += 1; - lineIndex += 1; - } - if (lineIndex < lines.length) { - lineIndex += 1; - } - totalHeight += - MARKDOWN_CODE_BLOCK_BASE_HEIGHT_PX + - Math.max(codeLineCount, 1) * MARKDOWN_CODE_LINE_HEIGHT_PX; - continue; - } - - if (isMarkdownHeading(trimmedLine)) { - startBlock(); - totalHeight += - estimateWrappedLineCount(trimmedLine.replace(/^#{1,6}\s+/, ""), charsPerLine) * - MARKDOWN_HEADING_LINE_HEIGHT_PX; - lineIndex += 1; - continue; - } - - if (isMarkdownBlockquote(trimmedLine)) { - startBlock(); - const quoteLines: string[] = []; - while (lineIndex < lines.length) { - const candidate = lines[lineIndex] ?? ""; - if (!isMarkdownBlockquote(candidate.trim())) break; - quoteLines.push(candidate.replace(/^\s*>\s?/, "")); - lineIndex += 1; - } - totalHeight += - estimateWrappedLinesForTexts(quoteLines, Math.max(charsPerLine - 3, 12)) * LINE_HEIGHT_PX + - 8; - continue; - } - - if ( - isMarkdownTableLine(rawLine) && - lineIndex + 1 < lines.length && - isMarkdownTableSeparator(lines[lineIndex + 1] ?? "") - ) { - startBlock(); - let rowCount = 0; - while (lineIndex < lines.length) { - const candidate = lines[lineIndex] ?? ""; - if (candidate.trim().length === 0 || !isMarkdownTableLine(candidate)) break; - rowCount += 1; - lineIndex += 1; - } - totalHeight += Math.max(rowCount, 2) * MARKDOWN_TABLE_ROW_HEIGHT_PX; - continue; - } - - if (isMarkdownListItem(trimmedLine)) { - startBlock(); - const listLines: string[] = []; - while (lineIndex < lines.length) { - const candidate = lines[lineIndex] ?? ""; - const candidateTrimmed = candidate.trim(); - if (candidateTrimmed.length === 0) break; - if (!isMarkdownListItem(candidateTrimmed) && !/^\s{2,}\S/.test(candidate)) break; - listLines.push(candidateTrimmed.replace(/^(?:[-*+]\s+|\d+\.\s+)\s?/, "")); - lineIndex += 1; - } - totalHeight += - estimateWrappedLinesForTexts(listLines, Math.max(charsPerLine - 3, 12)) * LINE_HEIGHT_PX + - Math.max(listLines.length - 1, 0) * MARKDOWN_LIST_ITEM_GAP_PX; - continue; - } - - startBlock(); - const paragraphLines: string[] = []; - while (lineIndex < lines.length) { - const candidate = lines[lineIndex] ?? ""; - const candidateTrimmed = candidate.trim(); - if (candidateTrimmed.length === 0) break; - if ( - candidateTrimmed.startsWith("```") || - isMarkdownHeading(candidateTrimmed) || - isMarkdownListItem(candidateTrimmed) || - isMarkdownBlockquote(candidateTrimmed) || - (isMarkdownTableLine(candidate) && - lineIndex + 1 < lines.length && - isMarkdownTableSeparator(lines[lineIndex + 1] ?? "")) - ) { - break; - } - paragraphLines.push(candidateTrimmed); - lineIndex += 1; - } - totalHeight += estimateWrappedLinesForTexts(paragraphLines, charsPerLine) * LINE_HEIGHT_PX; - } - - return totalHeight; -} - export function estimateTimelineMessageHeight( message: TimelineMessageHeightInput, layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, ): number { const timelineWidthPx = layout.timelineWidthPx ?? null; if (message.role === "assistant") { - return estimateAssistantMarkdownHeight(message.text, timelineWidthPx); + const charsPerLine = estimateCharsPerLineForAssistant(timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; } if (message.role === "user") { @@ -322,7 +163,9 @@ export function estimateTimelineMessageHeight( // `system` messages are not rendered in the chat timeline, but keep a stable // explicit branch in case they are present in timeline data. - return estimateAssistantMarkdownHeight(message.text, timelineWidthPx); + const charsPerLine = estimateCharsPerLineForAssistant(timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; } export function estimateTimelineWorkGroupHeight( From 54115703c06c1a0277ee18fc25b16c79e898287c Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sat, 14 Mar 2026 00:34:31 +0100 Subject: [PATCH 4/4] test(web): fix compact diff tree row count expectation --- apps/web/src/lib/turnDiffTree.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index 471d77075..60929f192 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -189,6 +189,6 @@ describe("countVisibleTurnDiffTreeNodes", () => { { path: "README.md", additions: 1, deletions: 0 }, ]); - expect(countVisibleTurnDiffTreeNodes(tree, true)).toBe(5); + expect(countVisibleTurnDiffTreeNodes(tree, true)).toBe(4); }); });