| author | |
| committer | |
| log | 867cf4cf091b2626930e58d46c9fee9f009b6916 |
| tree | eebb6eb5859b73a7b9bc744faa7e5553dc56e603 |
| parent | ae6f9715c6472670c0fae3b9cd0f44484f8e31d4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>9 files changed, 1465 insertions(+), 150 deletions(-)
apps/web/src/components/chat/ChangedFilesTree.test.tsx created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | import { TurnId } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { ChangedFilesTree } from "./ChangedFilesTree"; | |
| 6 | ||
| 7 | describe("ChangedFilesTree", () => { | |
| 8 | it.each([ | |
| 9 | { | |
| 10 | name: "a compacted single-chain directory", | |
| 11 | files: [ | |
| 12 | { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, | |
| 13 | { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, | |
| 14 | ], | |
| 15 | visibleLabels: ["apps/web/src"], | |
| 16 | hiddenLabels: ["index.ts", "main.ts"], | |
| 17 | }, | |
| 18 | { | |
| 19 | name: "a branch point after a compacted prefix", | |
| 20 | files: [ | |
| 21 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, | |
| 22 | { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, | |
| 23 | ], | |
| 24 | visibleLabels: ["apps/server/src"], | |
| 25 | hiddenLabels: ["git", "provider", "GitCore.ts", "CodexAdapter.ts"], | |
| 26 | }, | |
| 27 | { | |
| 28 | name: "mixed root files and nested compacted directories", | |
| 29 | files: [ | |
| 30 | { path: "README.md", additions: 1, deletions: 0 }, | |
| 31 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | |
| 32 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | |
| 33 | ], | |
| 34 | visibleLabels: ["README.md", "packages"], | |
| 35 | hiddenLabels: ["shared/src", "contracts/src", "git.ts", "orchestration.ts"], | |
| 36 | }, | |
| 37 | ])( | |
| 38 | "renders $name collapsed on the first render when collapse-all is active", | |
| 39 | ({ files, visibleLabels, hiddenLabels }) => { | |
| 40 | const markup = renderToStaticMarkup( | |
| 41 | <ChangedFilesTree | |
| 42 | turnId={TurnId.makeUnsafe("turn-1")} | |
| 43 | files={files} | |
| 44 | allDirectoriesExpanded={false} | |
| 45 | resolvedTheme="light" | |
| 46 | onOpenTurnDiff={() => {}} | |
| 47 | />, | |
| 48 | ); | |
| 49 | ||
| 50 | for (const label of visibleLabels) { | |
| 51 | expect(markup).toContain(label); | |
| 52 | } | |
| 53 | for (const label of hiddenLabels) { | |
| 54 | expect(markup).not.toContain(label); | |
| 55 | } | |
| 56 | }, | |
| 57 | ); | |
| 58 | ||
| 59 | it.each([ | |
| 60 | { | |
| 61 | name: "a compacted single-chain directory", | |
| 62 | files: [ | |
| 63 | { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, | |
| 64 | { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, | |
| 65 | ], | |
| 66 | visibleLabels: ["apps/web/src", "index.ts", "main.ts"], | |
| 67 | }, | |
| 68 | { | |
| 69 | name: "a branch point after a compacted prefix", | |
| 70 | files: [ | |
| 71 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, | |
| 72 | { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, | |
| 73 | ], | |
| 74 | visibleLabels: [ | |
| 75 | "apps/server/src", | |
| 76 | "git/Layers", | |
| 77 | "provider/Layers", | |
| 78 | "GitCore.ts", | |
| 79 | "CodexAdapter.ts", | |
| 80 | ], | |
| 81 | }, | |
| 82 | { | |
| 83 | name: "mixed root files and nested compacted directories", | |
| 84 | files: [ | |
| 85 | { path: "README.md", additions: 1, deletions: 0 }, | |
| 86 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | |
| 87 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | |
| 88 | ], | |
| 89 | visibleLabels: [ | |
| 90 | "README.md", | |
| 91 | "packages", | |
| 92 | "shared/src", | |
| 93 | "contracts/src", | |
| 94 | "git.ts", | |
| 95 | "orchestration.ts", | |
| 96 | ], | |
| 97 | }, | |
| 98 | ])( | |
| 99 | "renders $name expanded on the first render when expand-all is active", | |
| 100 | ({ files, visibleLabels }) => { | |
| 101 | const markup = renderToStaticMarkup( | |
| 102 | <ChangedFilesTree | |
| 103 | turnId={TurnId.makeUnsafe("turn-1")} | |
| 104 | files={files} | |
| 105 | allDirectoriesExpanded | |
| 106 | resolvedTheme="light" | |
| 107 | onOpenTurnDiff={() => {}} | |
| 108 | />, | |
| 109 | ); | |
| 110 | ||
| 111 | for (const label of visibleLabels) { | |
| 112 | expect(markup).toContain(label); | |
| 113 | } | |
| 114 | }, | |
| 115 | ); | |
| 116 | }); |
apps/web/src/components/chat/ChangedFilesTree.tsx+32-34| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { type TurnId } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | |
| 2 | import { memo, useCallback, useMemo, useState } from "react"; | |
| 3 | 3 | import { type TurnDiffFileChange } from "../../types"; |
| 4 | 4 | import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; |
| 5 | 5 | import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; |
| ... | ... | @@ -7,6 +7,8 @@ import { cn } from "~/lib/utils"; |
| 7 | 7 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 8 | 8 | import { VscodeEntryIcon } from "./VscodeEntryIcon"; |
| 9 | 9 | |
| 10 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; | |
| 11 | ||
| 10 | 12 | export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 11 | 13 | turnId: TurnId; |
| 12 | 14 | files: ReadonlyArray<TurnDiffFileChange>; |
| ... | ... | @@ -20,32 +22,39 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 20 | 22 | () => collectDirectoryPaths(treeNodes).join("\u0000"), |
| 21 | 23 | [treeNodes], |
| 22 | 24 | ); |
| 23 | const allDirectoryExpansionState = useMemo( | |
| 24 | () => | |
| 25 | buildDirectoryExpansionState( | |
| 26 | directoryPathsKey ? directoryPathsKey.split("\u0000") : [], | |
| 27 | allDirectoriesExpanded, | |
| 28 | ), | |
| 29 | [allDirectoriesExpanded, directoryPathsKey], | |
| 30 | ); | |
| 31 | const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(() => | |
| 32 | buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), | |
| 33 | ); | |
| 34 | useEffect(() => { | |
| 35 | setExpandedDirectories(allDirectoryExpansionState); | |
| 36 | }, [allDirectoryExpansionState]); | |
| 25 | const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`; | |
| 26 | const [directoryExpansionState, setDirectoryExpansionState] = useState<{ | |
| 27 | key: string; | |
| 28 | overrides: Record<string, boolean>; | |
| 29 | }>(() => ({ | |
| 30 | key: expansionStateKey, | |
| 31 | overrides: {}, | |
| 32 | })); | |
| 33 | const expandedDirectories = | |
| 34 | directoryExpansionState.key === expansionStateKey | |
| 35 | ? directoryExpansionState.overrides | |
| 36 | : EMPTY_DIRECTORY_OVERRIDES; | |
| 37 | 37 | |
| 38 | const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { | |
| 39 | setExpandedDirectories((current) => ({ | |
| 40 | ...current, | |
| 41 | [pathValue]: !(current[pathValue] ?? fallbackExpanded), | |
| 42 | })); | |
| 43 | }, []); | |
| 38 | const toggleDirectory = useCallback( | |
| 39 | (pathValue: string) => { | |
| 40 | setDirectoryExpansionState((current) => { | |
| 41 | const nextOverrides = current.key === expansionStateKey ? current.overrides : {}; | |
| 42 | return { | |
| 43 | key: expansionStateKey, | |
| 44 | overrides: { | |
| 45 | ...nextOverrides, | |
| 46 | [pathValue]: !(nextOverrides[pathValue] ?? allDirectoriesExpanded), | |
| 47 | }, | |
| 48 | }; | |
| 49 | }); | |
| 50 | }, | |
| 51 | [allDirectoriesExpanded, expansionStateKey], | |
| 52 | ); | |
| 44 | 53 | |
| 45 | 54 | const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { |
| 46 | 55 | const leftPadding = 8 + depth * 14; |
| 47 | 56 | if (node.kind === "directory") { |
| 48 | const isExpanded = expandedDirectories[node.path] ?? depth === 0; | |
| 57 | const isExpanded = expandedDirectories[node.path] ?? allDirectoriesExpanded; | |
| 49 | 58 | return ( |
| 50 | 59 | <div key={`dir:${node.path}`}> |
| 51 | 60 | <button |
| ... | ... | @@ -53,7 +62,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 53 | 62 | data-scroll-anchor-ignore |
| 54 | 63 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" |
| 55 | 64 | style={{ paddingLeft: `${leftPadding}px` }} |
| 56 | onClick={() => toggleDirectory(node.path, depth === 0)} | |
| 65 | onClick={() => toggleDirectory(node.path)} | |
| 57 | 66 | > |
| 58 | 67 | <ChevronRightIcon |
| 59 | 68 | aria-hidden="true" |
| ... | ... | @@ -124,14 +133,3 @@ function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] |
| 124 | 133 | } |
| 125 | 134 | return paths; |
| 126 | 135 | } |
| 127 | ||
| 128 | function buildDirectoryExpansionState( | |
| 129 | directoryPaths: ReadonlyArray<string>, | |
| 130 | expanded: boolean, | |
| 131 | ): Record<string, boolean> { | |
| 132 | const expandedState: Record<string, boolean> = {}; | |
| 133 | for (const directoryPath of directoryPaths) { | |
| 134 | expandedState[directoryPath] = expanded; | |
| 135 | } | |
| 136 | return expandedState; | |
| 137 | } |
apps/web/src/components/chat/MessagesTimeline.logic.ts+170| ... | ... | @@ -1,3 +1,11 @@ |
| 1 | import { type MessageId } from "@t3tools/contracts"; | |
| 2 | import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; | |
| 3 | import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; | |
| 4 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; | |
| 5 | import { estimateTimelineMessageHeight } from "../timelineHeight"; | |
| 6 | ||
| 7 | export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | |
| 8 | ||
| 1 | 9 | export interface TimelineDurationMessage { |
| 2 | 10 | id: string; |
| 3 | 11 | role: "user" | "assistant" | "system"; |
| ... | ... | @@ -5,6 +13,29 @@ export interface TimelineDurationMessage { |
| 5 | 13 | completedAt?: string | undefined; |
| 6 | 14 | } |
| 7 | 15 | |
| 16 | export type MessagesTimelineRow = | |
| 17 | | { | |
| 18 | kind: "work"; | |
| 19 | id: string; | |
| 20 | createdAt: string; | |
| 21 | groupedEntries: WorkLogEntry[]; | |
| 22 | } | |
| 23 | | { | |
| 24 | kind: "message"; | |
| 25 | id: string; | |
| 26 | createdAt: string; | |
| 27 | message: ChatMessage; | |
| 28 | durationStart: string; | |
| 29 | showCompletionDivider: boolean; | |
| 30 | } | |
| 31 | | { | |
| 32 | kind: "proposed-plan"; | |
| 33 | id: string; | |
| 34 | createdAt: string; | |
| 35 | proposedPlan: ProposedPlan; | |
| 36 | } | |
| 37 | | { kind: "working"; id: string; createdAt: string | null }; | |
| 38 | ||
| 8 | 39 | export function computeMessageDurationStart( |
| 9 | 40 | messages: ReadonlyArray<TimelineDurationMessage>, |
| 10 | 41 | ): Map<string, string> { |
| ... | ... | @@ -27,3 +58,142 @@ export function computeMessageDurationStart( |
| 27 | 58 | export function normalizeCompactToolLabel(value: string): string { |
| 28 | 59 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); |
| 29 | 60 | } |
| 61 | ||
| 62 | export function deriveMessagesTimelineRows(input: { | |
| 63 | timelineEntries: ReadonlyArray<TimelineEntry>; | |
| 64 | completionDividerBeforeEntryId: string | null; | |
| 65 | isWorking: boolean; | |
| 66 | activeTurnStartedAt: string | null; | |
| 67 | }): MessagesTimelineRow[] { | |
| 68 | const nextRows: MessagesTimelineRow[] = []; | |
| 69 | const durationStartByMessageId = computeMessageDurationStart( | |
| 70 | input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), | |
| 71 | ); | |
| 72 | ||
| 73 | for (let index = 0; index < input.timelineEntries.length; index += 1) { | |
| 74 | const timelineEntry = input.timelineEntries[index]; | |
| 75 | if (!timelineEntry) { | |
| 76 | continue; | |
| 77 | } | |
| 78 | ||
| 79 | if (timelineEntry.kind === "work") { | |
| 80 | const groupedEntries = [timelineEntry.entry]; | |
| 81 | let cursor = index + 1; | |
| 82 | while (cursor < input.timelineEntries.length) { | |
| 83 | const nextEntry = input.timelineEntries[cursor]; | |
| 84 | if (!nextEntry || nextEntry.kind !== "work") break; | |
| 85 | groupedEntries.push(nextEntry.entry); | |
| 86 | cursor += 1; | |
| 87 | } | |
| 88 | nextRows.push({ | |
| 89 | kind: "work", | |
| 90 | id: timelineEntry.id, | |
| 91 | createdAt: timelineEntry.createdAt, | |
| 92 | groupedEntries, | |
| 93 | }); | |
| 94 | index = cursor - 1; | |
| 95 | continue; | |
| 96 | } | |
| 97 | ||
| 98 | if (timelineEntry.kind === "proposed-plan") { | |
| 99 | nextRows.push({ | |
| 100 | kind: "proposed-plan", | |
| 101 | id: timelineEntry.id, | |
| 102 | createdAt: timelineEntry.createdAt, | |
| 103 | proposedPlan: timelineEntry.proposedPlan, | |
| 104 | }); | |
| 105 | continue; | |
| 106 | } | |
| 107 | ||
| 108 | nextRows.push({ | |
| 109 | kind: "message", | |
| 110 | id: timelineEntry.id, | |
| 111 | createdAt: timelineEntry.createdAt, | |
| 112 | message: timelineEntry.message, | |
| 113 | durationStart: | |
| 114 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, | |
| 115 | showCompletionDivider: | |
| 116 | timelineEntry.message.role === "assistant" && | |
| 117 | input.completionDividerBeforeEntryId === timelineEntry.id, | |
| 118 | }); | |
| 119 | } | |
| 120 | ||
| 121 | if (input.isWorking) { | |
| 122 | nextRows.push({ | |
| 123 | kind: "working", | |
| 124 | id: "working-indicator-row", | |
| 125 | createdAt: input.activeTurnStartedAt, | |
| 126 | }); | |
| 127 | } | |
| 128 | ||
| 129 | return nextRows; | |
| 130 | } | |
| 131 | ||
| 132 | export function estimateMessagesTimelineRowHeight( | |
| 133 | row: MessagesTimelineRow, | |
| 134 | input: { | |
| 135 | timelineWidthPx: number | null; | |
| 136 | expandedWorkGroups?: Readonly<Record<string, boolean>>; | |
| 137 | turnDiffSummaryByAssistantMessageId?: ReadonlyMap<MessageId, TurnDiffSummary>; | |
| 138 | }, | |
| 139 | ): number { | |
| 140 | switch (row.kind) { | |
| 141 | case "work": | |
| 142 | return estimateWorkRowHeight(row, input); | |
| 143 | case "proposed-plan": | |
| 144 | return estimateTimelineProposedPlanHeight(row.proposedPlan); | |
| 145 | case "working": | |
| 146 | return 40; | |
| 147 | case "message": { | |
| 148 | let estimate = estimateTimelineMessageHeight(row.message, { | |
| 149 | timelineWidthPx: input.timelineWidthPx, | |
| 150 | }); | |
| 151 | const turnDiffSummary = input.turnDiffSummaryByAssistantMessageId?.get(row.message.id); | |
| 152 | if (turnDiffSummary && turnDiffSummary.files.length > 0) { | |
| 153 | estimate += estimateChangedFilesCardHeight(turnDiffSummary); | |
| 154 | } | |
| 155 | return estimate; | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | function estimateWorkRowHeight( | |
| 161 | row: Extract<MessagesTimelineRow, { kind: "work" }>, | |
| 162 | input: { | |
| 163 | expandedWorkGroups?: Readonly<Record<string, boolean>>; | |
| 164 | }, | |
| 165 | ): number { | |
| 166 | const isExpanded = input.expandedWorkGroups?.[row.id] ?? false; | |
| 167 | const hasOverflow = row.groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 168 | const visibleEntries = | |
| 169 | hasOverflow && !isExpanded ? MAX_VISIBLE_WORK_LOG_ENTRIES : row.groupedEntries.length; | |
| 170 | const onlyToolEntries = row.groupedEntries.every((entry) => entry.tone === "tool"); | |
| 171 | const showHeader = hasOverflow || !onlyToolEntries; | |
| 172 | ||
| 173 | // Card chrome, optional header, and one compact work-entry row per visible entry. | |
| 174 | return 28 + (showHeader ? 26 : 0) + visibleEntries * 32; | |
| 175 | } | |
| 176 | ||
| 177 | function estimateTimelineProposedPlanHeight(proposedPlan: ProposedPlan): number { | |
| 178 | const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); | |
| 179 | return 120 + Math.min(estimatedLines * 22, 880); | |
| 180 | } | |
| 181 | ||
| 182 | function estimateChangedFilesCardHeight(turnDiffSummary: TurnDiffSummary): number { | |
| 183 | const treeNodes = buildTurnDiffTree(turnDiffSummary.files); | |
| 184 | const visibleNodeCount = countTurnDiffTreeNodes(treeNodes); | |
| 185 | ||
| 186 | // Card chrome: top/bottom padding, header row, and tree spacing. | |
| 187 | return 60 + visibleNodeCount * 25; | |
| 188 | } | |
| 189 | ||
| 190 | function countTurnDiffTreeNodes(nodes: ReadonlyArray<TurnDiffTreeNode>): number { | |
| 191 | let count = 0; | |
| 192 | for (const node of nodes) { | |
| 193 | count += 1; | |
| 194 | if (node.kind === "directory") { | |
| 195 | count += countTurnDiffTreeNodes(node.children); | |
| 196 | } | |
| 197 | } | |
| 198 | return count; | |
| 199 | } |
apps/web/src/components/chat/MessagesTimeline.tsx+75-102| ... | ... | @@ -35,13 +35,18 @@ import { |
| 35 | 35 | } from "lucide-react"; |
| 36 | 36 | import { Button } from "../ui/button"; |
| 37 | 37 | import { clamp } from "effect/Number"; |
| 38 | import { estimateTimelineMessageHeight } from "../timelineHeight"; | |
| 39 | 38 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 40 | 39 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 41 | 40 | import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 42 | 41 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 43 | 42 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 44 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; | |
| 43 | import { | |
| 44 | MAX_VISIBLE_WORK_LOG_ENTRIES, | |
| 45 | deriveMessagesTimelineRows, | |
| 46 | estimateMessagesTimelineRowHeight, | |
| 47 | normalizeCompactToolLabel, | |
| 48 | type MessagesTimelineRow, | |
| 49 | } from "./MessagesTimeline.logic"; | |
| 45 | 50 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 46 | 51 | import { |
| 47 | 52 | deriveDisplayedUserMessageState, |
| ... | ... | @@ -56,7 +61,6 @@ import { |
| 56 | 61 | textContainsInlineTerminalContextLabels, |
| 57 | 62 | } from "./userMessageTerminalContexts"; |
| 58 | 63 | |
| 59 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | |
| 60 | 64 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; |
| 61 | 65 | |
| 62 | 66 | interface MessagesTimelineProps { |
| ... | ... | @@ -81,6 +85,17 @@ interface MessagesTimelineProps { |
| 81 | 85 | resolvedTheme: "light" | "dark"; |
| 82 | 86 | timestampFormat: TimestampFormat; |
| 83 | 87 | workspaceRoot: string | undefined; |
| 88 | onVirtualizerSnapshot?: (snapshot: { | |
| 89 | totalSize: number; | |
| 90 | measurements: ReadonlyArray<{ | |
| 91 | id: string; | |
| 92 | kind: MessagesTimelineRow["kind"]; | |
| 93 | index: number; | |
| 94 | size: number; | |
| 95 | start: number; | |
| 96 | end: number; | |
| 97 | }>; | |
| 98 | }) => void; | |
| 84 | 99 | } |
| 85 | 100 | |
| 86 | 101 | export const MessagesTimeline = memo(function MessagesTimeline({ |
| ... | ... | @@ -105,6 +120,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 105 | 120 | resolvedTheme, |
| 106 | 121 | timestampFormat, |
| 107 | 122 | workspaceRoot, |
| 123 | onVirtualizerSnapshot, | |
| 108 | 124 | }: MessagesTimelineProps) { |
| 109 | 125 | const timelineRootRef = useRef<HTMLDivElement | null>(null); |
| 110 | 126 | const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null); |
| ... | ... | @@ -134,70 +150,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 134 | 150 | }; |
| 135 | 151 | }, [hasMessages, isWorking]); |
| 136 | 152 | |
| 137 | const rows = useMemo<TimelineRow[]>(() => { | |
| 138 | const nextRows: TimelineRow[] = []; | |
| 139 | const durationStartByMessageId = computeMessageDurationStart( | |
| 140 | timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), | |
| 141 | ); | |
| 142 | ||
| 143 | for (let index = 0; index < timelineEntries.length; index += 1) { | |
| 144 | const timelineEntry = timelineEntries[index]; | |
| 145 | if (!timelineEntry) { | |
| 146 | continue; | |
| 147 | } | |
| 148 | ||
| 149 | if (timelineEntry.kind === "work") { | |
| 150 | const groupedEntries = [timelineEntry.entry]; | |
| 151 | let cursor = index + 1; | |
| 152 | while (cursor < timelineEntries.length) { | |
| 153 | const nextEntry = timelineEntries[cursor]; | |
| 154 | if (!nextEntry || nextEntry.kind !== "work") break; | |
| 155 | groupedEntries.push(nextEntry.entry); | |
| 156 | cursor += 1; | |
| 157 | } | |
| 158 | nextRows.push({ | |
| 159 | kind: "work", | |
| 160 | id: timelineEntry.id, | |
| 161 | createdAt: timelineEntry.createdAt, | |
| 162 | groupedEntries, | |
| 163 | }); | |
| 164 | index = cursor - 1; | |
| 165 | continue; | |
| 166 | } | |
| 167 | ||
| 168 | if (timelineEntry.kind === "proposed-plan") { | |
| 169 | nextRows.push({ | |
| 170 | kind: "proposed-plan", | |
| 171 | id: timelineEntry.id, | |
| 172 | createdAt: timelineEntry.createdAt, | |
| 173 | proposedPlan: timelineEntry.proposedPlan, | |
| 174 | }); | |
| 175 | continue; | |
| 176 | } | |
| 177 | ||
| 178 | nextRows.push({ | |
| 179 | kind: "message", | |
| 180 | id: timelineEntry.id, | |
| 181 | createdAt: timelineEntry.createdAt, | |
| 182 | message: timelineEntry.message, | |
| 183 | durationStart: | |
| 184 | durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, | |
| 185 | showCompletionDivider: | |
| 186 | timelineEntry.message.role === "assistant" && | |
| 187 | completionDividerBeforeEntryId === timelineEntry.id, | |
| 188 | }); | |
| 189 | } | |
| 190 | ||
| 191 | if (isWorking) { | |
| 192 | nextRows.push({ | |
| 193 | kind: "working", | |
| 194 | id: "working-indicator-row", | |
| 195 | createdAt: activeTurnStartedAt, | |
| 196 | }); | |
| 197 | } | |
| 198 | ||
| 199 | return nextRows; | |
| 200 | }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); | |
| 153 | const rows = useMemo( | |
| 154 | () => | |
| 155 | deriveMessagesTimelineRows({ | |
| 156 | timelineEntries, | |
| 157 | completionDividerBeforeEntryId, | |
| 158 | isWorking, | |
| 159 | activeTurnStartedAt, | |
| 160 | }), | |
| 161 | [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], | |
| 162 | ); | |
| 201 | 163 | |
| 202 | 164 | const firstUnvirtualizedRowIndex = useMemo(() => { |
| 203 | 165 | const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); |
| ... | ... | @@ -241,19 +203,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 241 | 203 | minimum: 0, |
| 242 | 204 | maximum: rows.length, |
| 243 | 205 | }); |
| 206 | const virtualMeasurementScopeKey = | |
| 207 | timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; | |
| 244 | 208 | |
| 245 | 209 | const rowVirtualizer = useVirtualizer({ |
| 246 | 210 | count: virtualizedRowCount, |
| 247 | 211 | getScrollElement: () => scrollContainer, |
| 248 | // Use stable row ids so virtual measurements do not leak across thread switches. | |
| 249 | getItemKey: (index: number) => rows[index]?.id ?? index, | |
| 212 | // Scope cached row measurements to the current timeline width so offscreen | |
| 213 | // rows do not keep stale heights after wrapping changes. | |
| 214 | getItemKey: (index: number) => { | |
| 215 | const rowId = rows[index]?.id ?? String(index); | |
| 216 | return `${virtualMeasurementScopeKey}:${rowId}`; | |
| 217 | }, | |
| 250 | 218 | estimateSize: (index: number) => { |
| 251 | 219 | const row = rows[index]; |
| 252 | 220 | if (!row) return 96; |
| 253 | if (row.kind === "work") return 112; | |
| 254 | if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); | |
| 255 | if (row.kind === "working") return 40; | |
| 256 | return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); | |
| 221 | return estimateMessagesTimelineRowHeight(row, { | |
| 222 | expandedWorkGroups, | |
| 223 | timelineWidthPx, | |
| 224 | turnDiffSummaryByAssistantMessageId, | |
| 225 | }); | |
| 257 | 226 | }, |
| 258 | 227 | measureElement: measureVirtualElement, |
| 259 | 228 | useAnimationFrameWithResizeObserver: true, |
| ... | ... | @@ -295,6 +264,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 295 | 264 | } |
| 296 | 265 | }; |
| 297 | 266 | }, []); |
| 267 | useLayoutEffect(() => { | |
| 268 | if (!onVirtualizerSnapshot) { | |
| 269 | return; | |
| 270 | } | |
| 271 | onVirtualizerSnapshot({ | |
| 272 | totalSize: rowVirtualizer.getTotalSize(), | |
| 273 | measurements: rowVirtualizer.measurementsCache | |
| 274 | .slice(0, virtualizedRowCount) | |
| 275 | .flatMap((measurement) => { | |
| 276 | const row = rows[measurement.index]; | |
| 277 | if (!row) { | |
| 278 | return []; | |
| 279 | } | |
| 280 | return [ | |
| 281 | { | |
| 282 | id: row.id, | |
| 283 | kind: row.kind, | |
| 284 | index: measurement.index, | |
| 285 | size: measurement.size, | |
| 286 | start: measurement.start, | |
| 287 | end: measurement.end, | |
| 288 | }, | |
| 289 | ]; | |
| 290 | }), | |
| 291 | }); | |
| 292 | }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); | |
| 298 | 293 | |
| 299 | 294 | const virtualRows = rowVirtualizer.getVirtualItems(); |
| 300 | 295 | const nonVirtualizedRows = rows.slice(virtualizedRowCount); |
| ... | ... | @@ -311,6 +306,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 311 | 306 | const renderRowContent = (row: TimelineRow) => ( |
| 312 | 307 | <div |
| 313 | 308 | className="pb-4" |
| 309 | data-timeline-row-id={row.id} | |
| 314 | 310 | data-timeline-row-kind={row.kind} |
| 315 | 311 | data-message-id={row.kind === "message" ? row.message.id : undefined} |
| 316 | 312 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| ... | ... | @@ -585,6 +581,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 585 | 581 | <div |
| 586 | 582 | key={`virtual-row:${row.id}`} |
| 587 | 583 | data-index={virtualRow.index} |
| 584 | data-virtual-row-id={row.id} | |
| 585 | data-virtual-row-kind={row.kind} | |
| 586 | data-virtual-row-size={virtualRow.size} | |
| 587 | data-virtual-row-start={virtualRow.start} | |
| 588 | 588 | ref={rowVirtualizer.measureElement} |
| 589 | 589 | className="absolute left-0 top-0 w-full" |
| 590 | 590 | style={{ transform: `translateY(${virtualRow.start}px)` }} |
| ... | ... | @@ -605,35 +605,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 605 | 605 | |
| 606 | 606 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; |
| 607 | 607 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; |
| 608 | type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"]; | |
| 609 | type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"]; | |
| 610 | type TimelineRow = | |
| 611 | | { | |
| 612 | kind: "work"; | |
| 613 | id: string; | |
| 614 | createdAt: string; | |
| 615 | groupedEntries: TimelineWorkEntry[]; | |
| 616 | } | |
| 617 | | { | |
| 618 | kind: "message"; | |
| 619 | id: string; | |
| 620 | createdAt: string; | |
| 621 | message: TimelineMessage; | |
| 622 | durationStart: string; | |
| 623 | showCompletionDivider: boolean; | |
| 624 | } | |
| 625 | | { | |
| 626 | kind: "proposed-plan"; | |
| 627 | id: string; | |
| 628 | createdAt: string; | |
| 629 | proposedPlan: TimelineProposedPlan; | |
| 630 | } | |
| 631 | | { kind: "working"; id: string; createdAt: string | null }; | |
| 632 | ||
| 633 | function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { | |
| 634 | const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); | |
| 635 | return 120 + Math.min(estimatedLines * 22, 880); | |
| 636 | } | |
| 608 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; | |
| 609 | type TimelineRow = MessagesTimelineRow; | |
| 637 | 610 | |
| 638 | 611 | function formatWorkingTimer(startIso: string, endIso: string): string | null { |
| 639 | 612 | const startedAtMs = Date.parse(startIso); |
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx created+1037| ... | ... | @@ -0,0 +1,1037 @@ |
| 1 | import "../../index.css"; | |
| 2 | ||
| 3 | import { MessageId, type TurnId } from "@t3tools/contracts"; | |
| 4 | import { page } from "vitest/browser"; | |
| 5 | import { useCallback, useState, type ComponentProps } from "react"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 7 | import { render } from "vitest-browser-react"; | |
| 8 | ||
| 9 | import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic"; | |
| 10 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; | |
| 11 | import { MessagesTimeline } from "./MessagesTimeline"; | |
| 12 | import { | |
| 13 | deriveMessagesTimelineRows, | |
| 14 | estimateMessagesTimelineRowHeight, | |
| 15 | } from "./MessagesTimeline.logic"; | |
| 16 | ||
| 17 | const DEFAULT_VIEWPORT = { | |
| 18 | width: 960, | |
| 19 | height: 1_100, | |
| 20 | }; | |
| 21 | const MARKDOWN_CWD = "/repo/project"; | |
| 22 | ||
| 23 | interface RowMeasurement { | |
| 24 | actualHeightPx: number; | |
| 25 | estimatedHeightPx: number; | |
| 26 | timelineWidthPx: number; | |
| 27 | virtualizerSizePx: number; | |
| 28 | renderedInVirtualizedRegion: boolean; | |
| 29 | } | |
| 30 | ||
| 31 | interface VirtualizationScenario { | |
| 32 | name: string; | |
| 33 | targetRowId: string; | |
| 34 | props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | |
| 35 | maxEstimateDeltaPx: number; | |
| 36 | } | |
| 37 | ||
| 38 | interface VirtualizerSnapshot { | |
| 39 | totalSize: number; | |
| 40 | measurements: ReadonlyArray<{ | |
| 41 | id: string; | |
| 42 | kind: string; | |
| 43 | index: number; | |
| 44 | size: number; | |
| 45 | start: number; | |
| 46 | end: number; | |
| 47 | }>; | |
| 48 | } | |
| 49 | ||
| 50 | function MessagesTimelineBrowserHarness( | |
| 51 | props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">, | |
| 52 | ) { | |
| 53 | const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null); | |
| 54 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>( | |
| 55 | () => props.expandedWorkGroups, | |
| 56 | ); | |
| 57 | const handleToggleWorkGroup = useCallback( | |
| 58 | (groupId: string) => { | |
| 59 | setExpandedWorkGroups((current) => ({ | |
| 60 | ...current, | |
| 61 | [groupId]: !(current[groupId] ?? false), | |
| 62 | })); | |
| 63 | props.onToggleWorkGroup(groupId); | |
| 64 | }, | |
| 65 | [props], | |
| 66 | ); | |
| 67 | ||
| 68 | return ( | |
| 69 | <div | |
| 70 | ref={setScrollContainer} | |
| 71 | data-testid="messages-timeline-scroll-container" | |
| 72 | className="h-full overflow-y-auto overscroll-y-contain" | |
| 73 | > | |
| 74 | <MessagesTimeline | |
| 75 | {...props} | |
| 76 | scrollContainer={scrollContainer} | |
| 77 | expandedWorkGroups={expandedWorkGroups} | |
| 78 | onToggleWorkGroup={handleToggleWorkGroup} | |
| 79 | /> | |
| 80 | </div> | |
| 81 | ); | |
| 82 | } | |
| 83 | ||
| 84 | function isoAt(offsetSeconds: number): string { | |
| 85 | return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString(); | |
| 86 | } | |
| 87 | ||
| 88 | function createMessage(input: { | |
| 89 | id: string; | |
| 90 | role: ChatMessage["role"]; | |
| 91 | text: string; | |
| 92 | offsetSeconds: number; | |
| 93 | attachments?: ChatMessage["attachments"]; | |
| 94 | }): ChatMessage { | |
| 95 | return { | |
| 96 | id: MessageId.makeUnsafe(input.id), | |
| 97 | role: input.role, | |
| 98 | text: input.text, | |
| 99 | ...(input.attachments ? { attachments: input.attachments } : {}), | |
| 100 | createdAt: isoAt(input.offsetSeconds), | |
| 101 | ...(input.role === "assistant" ? { completedAt: isoAt(input.offsetSeconds + 1) } : {}), | |
| 102 | streaming: false, | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | function createToolWorkEntry(input: { | |
| 107 | id: string; | |
| 108 | offsetSeconds: number; | |
| 109 | label?: string; | |
| 110 | detail?: string; | |
| 111 | }): WorkLogEntry { | |
| 112 | return { | |
| 113 | id: input.id, | |
| 114 | createdAt: isoAt(input.offsetSeconds), | |
| 115 | label: input.label ?? "exec_command completed", | |
| 116 | ...(input.detail ? { detail: input.detail } : {}), | |
| 117 | tone: "tool", | |
| 118 | toolTitle: "exec_command", | |
| 119 | }; | |
| 120 | } | |
| 121 | ||
| 122 | function createPlan(input: { | |
| 123 | id: string; | |
| 124 | offsetSeconds: number; | |
| 125 | planMarkdown: string; | |
| 126 | }): ProposedPlan { | |
| 127 | return { | |
| 128 | id: input.id as ProposedPlan["id"], | |
| 129 | turnId: null, | |
| 130 | planMarkdown: input.planMarkdown, | |
| 131 | implementedAt: null, | |
| 132 | implementationThreadId: null, | |
| 133 | createdAt: isoAt(input.offsetSeconds), | |
| 134 | updatedAt: isoAt(input.offsetSeconds + 1), | |
| 135 | }; | |
| 136 | } | |
| 137 | ||
| 138 | function createBaseTimelineProps(input: { | |
| 139 | messages?: ChatMessage[]; | |
| 140 | proposedPlans?: ProposedPlan[]; | |
| 141 | workEntries?: WorkLogEntry[]; | |
| 142 | expandedWorkGroups?: Record<string, boolean>; | |
| 143 | completionDividerBeforeEntryId?: string | null; | |
| 144 | turnDiffSummaryByAssistantMessageId?: Map<MessageId, TurnDiffSummary>; | |
| 145 | onVirtualizerSnapshot?: ComponentProps<typeof MessagesTimeline>["onVirtualizerSnapshot"]; | |
| 146 | }): Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer"> { | |
| 147 | return { | |
| 148 | hasMessages: true, | |
| 149 | isWorking: false, | |
| 150 | activeTurnInProgress: false, | |
| 151 | activeTurnStartedAt: null, | |
| 152 | timelineEntries: deriveTimelineEntries( | |
| 153 | input.messages ?? [], | |
| 154 | input.proposedPlans ?? [], | |
| 155 | input.workEntries ?? [], | |
| 156 | ), | |
| 157 | completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null, | |
| 158 | completionSummary: null, | |
| 159 | turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(), | |
| 160 | nowIso: isoAt(10_000), | |
| 161 | expandedWorkGroups: input.expandedWorkGroups ?? {}, | |
| 162 | onToggleWorkGroup: () => {}, | |
| 163 | onOpenTurnDiff: () => {}, | |
| 164 | revertTurnCountByUserMessageId: new Map(), | |
| 165 | onRevertUserMessage: () => {}, | |
| 166 | isRevertingCheckpoint: false, | |
| 167 | onImageExpand: () => {}, | |
| 168 | markdownCwd: MARKDOWN_CWD, | |
| 169 | resolvedTheme: "light", | |
| 170 | timestampFormat: "locale", | |
| 171 | workspaceRoot: MARKDOWN_CWD, | |
| 172 | ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}), | |
| 173 | }; | |
| 174 | } | |
| 175 | ||
| 176 | function createFillerMessages(input: { | |
| 177 | prefix: string; | |
| 178 | startOffsetSeconds: number; | |
| 179 | pairCount: number; | |
| 180 | }): ChatMessage[] { | |
| 181 | const messages: ChatMessage[] = []; | |
| 182 | for (let index = 0; index < input.pairCount; index += 1) { | |
| 183 | const baseOffset = input.startOffsetSeconds + index * 4; | |
| 184 | messages.push( | |
| 185 | createMessage({ | |
| 186 | id: `${input.prefix}-user-${index}`, | |
| 187 | role: "user", | |
| 188 | text: `filler user message ${index}`, | |
| 189 | offsetSeconds: baseOffset, | |
| 190 | }), | |
| 191 | ); | |
| 192 | messages.push( | |
| 193 | createMessage({ | |
| 194 | id: `${input.prefix}-assistant-${index}`, | |
| 195 | role: "assistant", | |
| 196 | text: `filler assistant message ${index}`, | |
| 197 | offsetSeconds: baseOffset + 1, | |
| 198 | }), | |
| 199 | ); | |
| 200 | } | |
| 201 | return messages; | |
| 202 | } | |
| 203 | ||
| 204 | function createChangedFilesSummary( | |
| 205 | targetMessageId: MessageId, | |
| 206 | files: TurnDiffSummary["files"], | |
| 207 | ): Map<MessageId, TurnDiffSummary> { | |
| 208 | return new Map([ | |
| 209 | [ | |
| 210 | targetMessageId, | |
| 211 | { | |
| 212 | turnId: "turn-changed-files" as TurnId, | |
| 213 | completedAt: isoAt(10), | |
| 214 | assistantMessageId: targetMessageId, | |
| 215 | files, | |
| 216 | }, | |
| 217 | ], | |
| 218 | ]); | |
| 219 | } | |
| 220 | ||
| 221 | function createChangedFilesScenario(input: { | |
| 222 | name: string; | |
| 223 | rowId: string; | |
| 224 | files: TurnDiffSummary["files"]; | |
| 225 | maxEstimateDeltaPx?: number; | |
| 226 | }): VirtualizationScenario { | |
| 227 | const beforeMessages = createFillerMessages({ | |
| 228 | prefix: `${input.rowId}-before`, | |
| 229 | startOffsetSeconds: 0, | |
| 230 | pairCount: 2, | |
| 231 | }); | |
| 232 | const afterMessages = createFillerMessages({ | |
| 233 | prefix: `${input.rowId}-after`, | |
| 234 | startOffsetSeconds: 40, | |
| 235 | pairCount: 8, | |
| 236 | }); | |
| 237 | const changedFilesMessage = createMessage({ | |
| 238 | id: input.rowId, | |
| 239 | role: "assistant", | |
| 240 | text: "Validation passed on the merged tree.", | |
| 241 | offsetSeconds: 12, | |
| 242 | }); | |
| 243 | ||
| 244 | return { | |
| 245 | name: input.name, | |
| 246 | targetRowId: changedFilesMessage.id, | |
| 247 | props: createBaseTimelineProps({ | |
| 248 | messages: [...beforeMessages, changedFilesMessage, ...afterMessages], | |
| 249 | turnDiffSummaryByAssistantMessageId: createChangedFilesSummary( | |
| 250 | changedFilesMessage.id, | |
| 251 | input.files, | |
| 252 | ), | |
| 253 | }), | |
| 254 | maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 72, | |
| 255 | }; | |
| 256 | } | |
| 257 | ||
| 258 | function createAssistantMessageScenario(input: { | |
| 259 | name: string; | |
| 260 | rowId: string; | |
| 261 | text: string; | |
| 262 | maxEstimateDeltaPx?: number; | |
| 263 | }): VirtualizationScenario { | |
| 264 | const beforeMessages = createFillerMessages({ | |
| 265 | prefix: `${input.rowId}-before`, | |
| 266 | startOffsetSeconds: 0, | |
| 267 | pairCount: 2, | |
| 268 | }); | |
| 269 | const afterMessages = createFillerMessages({ | |
| 270 | prefix: `${input.rowId}-after`, | |
| 271 | startOffsetSeconds: 40, | |
| 272 | pairCount: 8, | |
| 273 | }); | |
| 274 | const assistantMessage = createMessage({ | |
| 275 | id: input.rowId, | |
| 276 | role: "assistant", | |
| 277 | text: input.text, | |
| 278 | offsetSeconds: 12, | |
| 279 | }); | |
| 280 | ||
| 281 | return { | |
| 282 | name: input.name, | |
| 283 | targetRowId: assistantMessage.id, | |
| 284 | props: createBaseTimelineProps({ | |
| 285 | messages: [...beforeMessages, assistantMessage, ...afterMessages], | |
| 286 | }), | |
| 287 | maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 16, | |
| 288 | }; | |
| 289 | } | |
| 290 | ||
| 291 | function buildStaticScenarios(): VirtualizationScenario[] { | |
| 292 | const beforeMessages = createFillerMessages({ | |
| 293 | prefix: "before", | |
| 294 | startOffsetSeconds: 0, | |
| 295 | pairCount: 2, | |
| 296 | }); | |
| 297 | const afterMessages = createFillerMessages({ | |
| 298 | prefix: "after", | |
| 299 | startOffsetSeconds: 40, | |
| 300 | pairCount: 8, | |
| 301 | }); | |
| 302 | ||
| 303 | const longUserMessage = createMessage({ | |
| 304 | id: "target-user-long", | |
| 305 | role: "user", | |
| 306 | text: "x".repeat(3_200), | |
| 307 | offsetSeconds: 12, | |
| 308 | }); | |
| 309 | const workEntries = Array.from({ length: 4 }, (_, index) => | |
| 310 | createToolWorkEntry({ | |
| 311 | id: `target-work-${index}`, | |
| 312 | offsetSeconds: 12 + index, | |
| 313 | detail: `tool output line ${index + 1}`, | |
| 314 | }), | |
| 315 | ); | |
| 316 | const moderatePlan = createPlan({ | |
| 317 | id: "target-plan", | |
| 318 | offsetSeconds: 12, | |
| 319 | planMarkdown: [ | |
| 320 | "# Stabilize virtualization", | |
| 321 | "", | |
| 322 | "- Gather baseline measurements", | |
| 323 | "- Add browser harness coverage", | |
| 324 | "- Compare estimated and rendered heights", | |
| 325 | "- Fix the broken rows without broad refactors", | |
| 326 | "- Re-run lint and typecheck", | |
| 327 | ].join("\n"), | |
| 328 | }); | |
| 329 | return [ | |
| 330 | { | |
| 331 | name: "long user message", | |
| 332 | targetRowId: longUserMessage.id, | |
| 333 | props: createBaseTimelineProps({ | |
| 334 | messages: [...beforeMessages, longUserMessage, ...afterMessages], | |
| 335 | }), | |
| 336 | maxEstimateDeltaPx: 56, | |
| 337 | }, | |
| 338 | { | |
| 339 | name: "grouped work log row", | |
| 340 | targetRowId: workEntries[0]!.id, | |
| 341 | props: createBaseTimelineProps({ | |
| 342 | messages: [...beforeMessages, ...afterMessages], | |
| 343 | workEntries, | |
| 344 | }), | |
| 345 | maxEstimateDeltaPx: 56, | |
| 346 | }, | |
| 347 | { | |
| 348 | name: "expanded grouped work log row with show more enabled", | |
| 349 | targetRowId: "target-work-expanded-0", | |
| 350 | props: createBaseTimelineProps({ | |
| 351 | messages: [...beforeMessages, ...afterMessages], | |
| 352 | workEntries: Array.from({ length: 10 }, (_, index) => | |
| 353 | createToolWorkEntry({ | |
| 354 | id: `target-work-expanded-${index}`, | |
| 355 | offsetSeconds: 12 + index, | |
| 356 | detail: `tool output line ${index + 1}`, | |
| 357 | }), | |
| 358 | ), | |
| 359 | expandedWorkGroups: { | |
| 360 | "target-work-expanded-0": true, | |
| 361 | }, | |
| 362 | }), | |
| 363 | maxEstimateDeltaPx: 72, | |
| 364 | }, | |
| 365 | { | |
| 366 | name: "proposed plan row", | |
| 367 | targetRowId: moderatePlan.id, | |
| 368 | props: createBaseTimelineProps({ | |
| 369 | messages: [...beforeMessages, ...afterMessages], | |
| 370 | proposedPlans: [moderatePlan], | |
| 371 | }), | |
| 372 | maxEstimateDeltaPx: 96, | |
| 373 | }, | |
| 374 | createAssistantMessageScenario({ | |
| 375 | name: "assistant single-paragraph row with plain prose", | |
| 376 | rowId: "target-assistant-plain-prose", | |
| 377 | text: [ | |
| 378 | "The host is still expanding to content somewhere in the grid layout.", | |
| 379 | "I'm stripping it back further to a plain block container so the test width", | |
| 380 | "is actually the timeline width.", | |
| 381 | ].join(" "), | |
| 382 | }), | |
| 383 | createAssistantMessageScenario({ | |
| 384 | name: "assistant single-paragraph row with inline code", | |
| 385 | rowId: "target-assistant-inline-code", | |
| 386 | text: [ | |
| 387 | "Typecheck found one exact-optional-property issue in the browser harness:", | |
| 388 | "I was always passing `onVirtualizerSnapshot`, including `undefined`.", | |
| 389 | "I'm tightening that object construction and rerunning the checks.", | |
| 390 | ].join(" "), | |
| 391 | maxEstimateDeltaPx: 28, | |
| 392 | }), | |
| 393 | createChangedFilesScenario({ | |
| 394 | name: "assistant changed-files row with a compacted single-chain directory", | |
| 395 | rowId: "target-assistant-changed-files-single-chain", | |
| 396 | files: [ | |
| 397 | { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 37, deletions: 45 }, | |
| 398 | { | |
| 399 | path: "apps/web/src/components/chat/ChangedFilesTree.test.tsx", | |
| 400 | additions: 0, | |
| 401 | deletions: 26, | |
| 402 | }, | |
| 403 | ], | |
| 404 | }), | |
| 405 | createChangedFilesScenario({ | |
| 406 | name: "assistant changed-files row with a branch after compaction", | |
| 407 | rowId: "target-assistant-changed-files-branch-point", | |
| 408 | files: [ | |
| 409 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, | |
| 410 | { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, | |
| 411 | { | |
| 412 | path: "apps/server/src/provider/Layers/CodexAdapter.ts", | |
| 413 | additions: 27, | |
| 414 | deletions: 8, | |
| 415 | }, | |
| 416 | { | |
| 417 | path: "apps/server/src/provider/Layers/CodexAdapter.test.ts", | |
| 418 | additions: 36, | |
| 419 | deletions: 0, | |
| 420 | }, | |
| 421 | ], | |
| 422 | }), | |
| 423 | createChangedFilesScenario({ | |
| 424 | name: "assistant changed-files row with mixed root and nested entries", | |
| 425 | rowId: "target-assistant-changed-files-mixed-root", | |
| 426 | files: [ | |
| 427 | { path: "README.md", additions: 5, deletions: 1 }, | |
| 428 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | |
| 429 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | |
| 430 | ], | |
| 431 | }), | |
| 432 | ]; | |
| 433 | } | |
| 434 | ||
| 435 | async function nextFrame(): Promise<void> { | |
| 436 | await new Promise<void>((resolve) => { | |
| 437 | window.requestAnimationFrame(() => resolve()); | |
| 438 | }); | |
| 439 | } | |
| 440 | ||
| 441 | async function waitForLayout(): Promise<void> { | |
| 442 | await nextFrame(); | |
| 443 | await nextFrame(); | |
| 444 | await nextFrame(); | |
| 445 | } | |
| 446 | ||
| 447 | async function setViewport(viewport: { width: number; height: number }): Promise<void> { | |
| 448 | await page.viewport(viewport.width, viewport.height); | |
| 449 | await waitForLayout(); | |
| 450 | } | |
| 451 | ||
| 452 | async function waitForProductionStyles(): Promise<void> { | |
| 453 | await vi.waitFor( | |
| 454 | () => { | |
| 455 | expect( | |
| 456 | getComputedStyle(document.documentElement).getPropertyValue("--background").trim(), | |
| 457 | ).not.toBe(""); | |
| 458 | expect(getComputedStyle(document.body).marginTop).toBe("0px"); | |
| 459 | }, | |
| 460 | { timeout: 4_000, interval: 16 }, | |
| 461 | ); | |
| 462 | } | |
| 463 | ||
| 464 | async function waitForElement<T extends Element>( | |
| 465 | query: () => T | null, | |
| 466 | errorMessage: string, | |
| 467 | ): Promise<T> { | |
| 468 | let element: T | null = null; | |
| 469 | await vi.waitFor( | |
| 470 | () => { | |
| 471 | element = query(); | |
| 472 | expect(element, errorMessage).toBeTruthy(); | |
| 473 | }, | |
| 474 | { timeout: 8_000, interval: 16 }, | |
| 475 | ); | |
| 476 | if (!element) { | |
| 477 | throw new Error(errorMessage); | |
| 478 | } | |
| 479 | return element; | |
| 480 | } | |
| 481 | ||
| 482 | async function measureTimelineRow(input: { | |
| 483 | host: HTMLElement; | |
| 484 | props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | |
| 485 | targetRowId: string; | |
| 486 | }): Promise<RowMeasurement> { | |
| 487 | const scrollContainer = await waitForElement( | |
| 488 | () => | |
| 489 | input.host.querySelector<HTMLDivElement>( | |
| 490 | '[data-testid="messages-timeline-scroll-container"]', | |
| 491 | ), | |
| 492 | "Unable to find MessagesTimeline scroll container.", | |
| 493 | ); | |
| 494 | ||
| 495 | const rowSelector = `[data-timeline-row-id="${input.targetRowId}"]`; | |
| 496 | const virtualRowSelector = `[data-virtual-row-id="${input.targetRowId}"]`; | |
| 497 | ||
| 498 | let timelineWidthPx = 0; | |
| 499 | let actualHeightPx = 0; | |
| 500 | let virtualizerSizePx = 0; | |
| 501 | let renderedInVirtualizedRegion = false; | |
| 502 | ||
| 503 | await vi.waitFor( | |
| 504 | async () => { | |
| 505 | scrollContainer.scrollTop = 0; | |
| 506 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 507 | await waitForLayout(); | |
| 508 | ||
| 509 | const rowElement = input.host.querySelector<HTMLElement>(rowSelector); | |
| 510 | const virtualRowElement = input.host.querySelector<HTMLElement>(virtualRowSelector); | |
| 511 | const timelineRoot = input.host.querySelector<HTMLElement>('[data-timeline-root="true"]'); | |
| 512 | ||
| 513 | expect(rowElement, "Unable to locate target timeline row.").toBeTruthy(); | |
| 514 | expect(virtualRowElement, "Unable to locate target virtualized wrapper.").toBeTruthy(); | |
| 515 | expect(timelineRoot, "Unable to locate MessagesTimeline root.").toBeTruthy(); | |
| 516 | ||
| 517 | timelineWidthPx = timelineRoot!.getBoundingClientRect().width; | |
| 518 | actualHeightPx = rowElement!.getBoundingClientRect().height; | |
| 519 | virtualizerSizePx = Number.parseFloat(virtualRowElement!.dataset.virtualRowSize ?? "0"); | |
| 520 | renderedInVirtualizedRegion = virtualRowElement!.hasAttribute("data-index"); | |
| 521 | ||
| 522 | expect(timelineWidthPx).toBeGreaterThan(0); | |
| 523 | expect(actualHeightPx).toBeGreaterThan(0); | |
| 524 | expect(virtualizerSizePx).toBeGreaterThan(0); | |
| 525 | expect(renderedInVirtualizedRegion).toBe(true); | |
| 526 | }, | |
| 527 | { timeout: 8_000, interval: 16 }, | |
| 528 | ); | |
| 529 | ||
| 530 | const rows = deriveMessagesTimelineRows({ | |
| 531 | timelineEntries: input.props.timelineEntries, | |
| 532 | completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId, | |
| 533 | isWorking: input.props.isWorking, | |
| 534 | activeTurnStartedAt: input.props.activeTurnStartedAt, | |
| 535 | }); | |
| 536 | const targetRow = rows.find((row) => row.id === input.targetRowId); | |
| 537 | expect(targetRow, `Unable to derive target row ${input.targetRowId}.`).toBeTruthy(); | |
| 538 | ||
| 539 | return { | |
| 540 | actualHeightPx, | |
| 541 | estimatedHeightPx: estimateMessagesTimelineRowHeight(targetRow!, { | |
| 542 | expandedWorkGroups: input.props.expandedWorkGroups, | |
| 543 | timelineWidthPx, | |
| 544 | turnDiffSummaryByAssistantMessageId: input.props.turnDiffSummaryByAssistantMessageId, | |
| 545 | }), | |
| 546 | timelineWidthPx, | |
| 547 | virtualizerSizePx, | |
| 548 | renderedInVirtualizedRegion, | |
| 549 | }; | |
| 550 | } | |
| 551 | ||
| 552 | async function mountMessagesTimeline(input: { | |
| 553 | props: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">; | |
| 554 | viewport?: { width: number; height: number }; | |
| 555 | }) { | |
| 556 | const viewport = input.viewport ?? DEFAULT_VIEWPORT; | |
| 557 | await setViewport(viewport); | |
| 558 | await waitForProductionStyles(); | |
| 559 | ||
| 560 | const host = document.createElement("div"); | |
| 561 | host.style.width = `${viewport.width}px`; | |
| 562 | host.style.minWidth = `${viewport.width}px`; | |
| 563 | host.style.maxWidth = `${viewport.width}px`; | |
| 564 | host.style.height = `${viewport.height}px`; | |
| 565 | host.style.minHeight = `${viewport.height}px`; | |
| 566 | host.style.maxHeight = `${viewport.height}px`; | |
| 567 | host.style.display = "block"; | |
| 568 | host.style.overflow = "hidden"; | |
| 569 | document.body.append(host); | |
| 570 | ||
| 571 | const screen = await render(<MessagesTimelineBrowserHarness {...input.props} />, { | |
| 572 | container: host, | |
| 573 | }); | |
| 574 | await waitForLayout(); | |
| 575 | ||
| 576 | return { | |
| 577 | host, | |
| 578 | rerender: async ( | |
| 579 | nextProps: Omit<ComponentProps<typeof MessagesTimeline>, "scrollContainer">, | |
| 580 | ) => { | |
| 581 | await screen.rerender(<MessagesTimelineBrowserHarness {...nextProps} />); | |
| 582 | await waitForLayout(); | |
| 583 | }, | |
| 584 | setContainerSize: async (nextViewport: { width: number; height: number }) => { | |
| 585 | await setViewport(nextViewport); | |
| 586 | host.style.width = `${nextViewport.width}px`; | |
| 587 | host.style.minWidth = `${nextViewport.width}px`; | |
| 588 | host.style.maxWidth = `${nextViewport.width}px`; | |
| 589 | host.style.height = `${nextViewport.height}px`; | |
| 590 | host.style.minHeight = `${nextViewport.height}px`; | |
| 591 | host.style.maxHeight = `${nextViewport.height}px`; | |
| 592 | await waitForLayout(); | |
| 593 | }, | |
| 594 | cleanup: async () => { | |
| 595 | await screen.unmount(); | |
| 596 | host.remove(); | |
| 597 | }, | |
| 598 | }; | |
| 599 | } | |
| 600 | ||
| 601 | async function measureRenderedRowActualHeight(input: { | |
| 602 | host: HTMLElement; | |
| 603 | targetRowId: string; | |
| 604 | }): Promise<number> { | |
| 605 | const rowElement = await waitForElement( | |
| 606 | () => input.host.querySelector<HTMLElement>(`[data-timeline-row-id="${input.targetRowId}"]`), | |
| 607 | `Unable to locate rendered row ${input.targetRowId}.`, | |
| 608 | ); | |
| 609 | return rowElement.getBoundingClientRect().height; | |
| 610 | } | |
| 611 | ||
| 612 | describe("MessagesTimeline virtualization harness", () => { | |
| 613 | beforeEach(async () => { | |
| 614 | document.body.innerHTML = ""; | |
| 615 | await setViewport(DEFAULT_VIEWPORT); | |
| 616 | }); | |
| 617 | ||
| 618 | afterEach(() => { | |
| 619 | document.body.innerHTML = ""; | |
| 620 | }); | |
| 621 | ||
| 622 | it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => { | |
| 623 | const mounted = await mountMessagesTimeline({ props: scenario.props }); | |
| 624 | ||
| 625 | try { | |
| 626 | const measurement = await measureTimelineRow({ | |
| 627 | host: mounted.host, | |
| 628 | props: scenario.props, | |
| 629 | targetRowId: scenario.targetRowId, | |
| 630 | }); | |
| 631 | ||
| 632 | expect( | |
| 633 | Math.abs(measurement.actualHeightPx - measurement.estimatedHeightPx), | |
| 634 | `estimate delta for ${scenario.name}`, | |
| 635 | ).toBeLessThanOrEqual(scenario.maxEstimateDeltaPx); | |
| 636 | } finally { | |
| 637 | await mounted.cleanup(); | |
| 638 | } | |
| 639 | }); | |
| 640 | ||
| 641 | it("keeps the changed-files row virtualizer size in sync after collapsing directories", async () => { | |
| 642 | const beforeMessages = createFillerMessages({ | |
| 643 | prefix: "before-collapse", | |
| 644 | startOffsetSeconds: 0, | |
| 645 | pairCount: 2, | |
| 646 | }); | |
| 647 | const afterMessages = createFillerMessages({ | |
| 648 | prefix: "after-collapse", | |
| 649 | startOffsetSeconds: 40, | |
| 650 | pairCount: 8, | |
| 651 | }); | |
| 652 | const targetMessage = createMessage({ | |
| 653 | id: "target-assistant-collapse", | |
| 654 | role: "assistant", | |
| 655 | text: "Validation passed on the merged tree.", | |
| 656 | offsetSeconds: 12, | |
| 657 | }); | |
| 658 | const props = createBaseTimelineProps({ | |
| 659 | messages: [...beforeMessages, targetMessage, ...afterMessages], | |
| 660 | turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ | |
| 661 | { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, | |
| 662 | { | |
| 663 | path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", | |
| 664 | additions: 4, | |
| 665 | deletions: 3, | |
| 666 | }, | |
| 667 | { | |
| 668 | path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", | |
| 669 | additions: 131, | |
| 670 | deletions: 128, | |
| 671 | }, | |
| 672 | { | |
| 673 | path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", | |
| 674 | additions: 1, | |
| 675 | deletions: 1, | |
| 676 | }, | |
| 677 | { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, | |
| 678 | { | |
| 679 | path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", | |
| 680 | additions: 106, | |
| 681 | deletions: 112, | |
| 682 | }, | |
| 683 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, | |
| 684 | { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, | |
| 685 | { | |
| 686 | path: "apps/web/src/components/chat/MessagesTimeline.tsx", | |
| 687 | additions: 52, | |
| 688 | deletions: 7, | |
| 689 | }, | |
| 690 | { | |
| 691 | path: "apps/web/src/components/chat/ChangedFilesTree.tsx", | |
| 692 | additions: 32, | |
| 693 | deletions: 4, | |
| 694 | }, | |
| 695 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | |
| 696 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | |
| 697 | ]), | |
| 698 | }); | |
| 699 | const mounted = await mountMessagesTimeline({ | |
| 700 | props, | |
| 701 | viewport: { width: 320, height: 700 }, | |
| 702 | }); | |
| 703 | ||
| 704 | try { | |
| 705 | const beforeCollapse = await measureTimelineRow({ | |
| 706 | host: mounted.host, | |
| 707 | props, | |
| 708 | targetRowId: targetMessage.id, | |
| 709 | }); | |
| 710 | const targetRowElement = mounted.host.querySelector<HTMLElement>( | |
| 711 | `[data-timeline-row-id="${targetMessage.id}"]`, | |
| 712 | ); | |
| 713 | expect(targetRowElement, "Unable to locate target changed-files row.").toBeTruthy(); | |
| 714 | ||
| 715 | const collapseAllButton = | |
| 716 | Array.from(targetRowElement!.querySelectorAll<HTMLButtonElement>("button")).find( | |
| 717 | (button) => button.textContent?.trim() === "Collapse all", | |
| 718 | ) ?? null; | |
| 719 | expect(collapseAllButton, 'Unable to find "Collapse all" button.').toBeTruthy(); | |
| 720 | ||
| 721 | collapseAllButton!.click(); | |
| 722 | ||
| 723 | await vi.waitFor( | |
| 724 | async () => { | |
| 725 | const afterCollapse = await measureTimelineRow({ | |
| 726 | host: mounted.host, | |
| 727 | props, | |
| 728 | targetRowId: targetMessage.id, | |
| 729 | }); | |
| 730 | expect(afterCollapse.actualHeightPx).toBeLessThan(beforeCollapse.actualHeightPx - 24); | |
| 731 | }, | |
| 732 | { timeout: 8_000, interval: 16 }, | |
| 733 | ); | |
| 734 | ||
| 735 | const afterCollapse = await measureTimelineRow({ | |
| 736 | host: mounted.host, | |
| 737 | props, | |
| 738 | targetRowId: targetMessage.id, | |
| 739 | }); | |
| 740 | expect( | |
| 741 | Math.abs(afterCollapse.actualHeightPx - afterCollapse.virtualizerSizePx), | |
| 742 | ).toBeLessThanOrEqual(8); | |
| 743 | } finally { | |
| 744 | await mounted.cleanup(); | |
| 745 | } | |
| 746 | }); | |
| 747 | ||
| 748 | it("keeps the work-log row virtualizer size in sync after show more expands the group", async () => { | |
| 749 | const beforeMessages = createFillerMessages({ | |
| 750 | prefix: "before-worklog-expand", | |
| 751 | startOffsetSeconds: 0, | |
| 752 | pairCount: 2, | |
| 753 | }); | |
| 754 | const afterMessages = createFillerMessages({ | |
| 755 | prefix: "after-worklog-expand", | |
| 756 | startOffsetSeconds: 40, | |
| 757 | pairCount: 8, | |
| 758 | }); | |
| 759 | const workEntries = Array.from({ length: 10 }, (_, index) => | |
| 760 | createToolWorkEntry({ | |
| 761 | id: `target-work-toggle-${index}`, | |
| 762 | offsetSeconds: 12 + index, | |
| 763 | detail: `tool output line ${index + 1}`, | |
| 764 | }), | |
| 765 | ); | |
| 766 | const props = createBaseTimelineProps({ | |
| 767 | messages: [...beforeMessages, ...afterMessages], | |
| 768 | workEntries, | |
| 769 | }); | |
| 770 | const mounted = await mountMessagesTimeline({ props }); | |
| 771 | ||
| 772 | try { | |
| 773 | const beforeExpand = await measureTimelineRow({ | |
| 774 | host: mounted.host, | |
| 775 | props, | |
| 776 | targetRowId: workEntries[0]!.id, | |
| 777 | }); | |
| 778 | const targetRowElement = mounted.host.querySelector<HTMLElement>( | |
| 779 | `[data-timeline-row-id="${workEntries[0]!.id}"]`, | |
| 780 | ); | |
| 781 | expect(targetRowElement, "Unable to locate target work-log row.").toBeTruthy(); | |
| 782 | ||
| 783 | const showMoreButton = | |
| 784 | Array.from(targetRowElement!.querySelectorAll<HTMLButtonElement>("button")).find((button) => | |
| 785 | button.textContent?.includes("Show 4 more"), | |
| 786 | ) ?? null; | |
| 787 | expect(showMoreButton, 'Unable to find "Show more" button.').toBeTruthy(); | |
| 788 | ||
| 789 | showMoreButton!.click(); | |
| 790 | ||
| 791 | await vi.waitFor( | |
| 792 | async () => { | |
| 793 | const afterExpand = await measureTimelineRow({ | |
| 794 | host: mounted.host, | |
| 795 | props, | |
| 796 | targetRowId: workEntries[0]!.id, | |
| 797 | }); | |
| 798 | expect(afterExpand.actualHeightPx).toBeGreaterThan(beforeExpand.actualHeightPx + 72); | |
| 799 | }, | |
| 800 | { timeout: 8_000, interval: 16 }, | |
| 801 | ); | |
| 802 | ||
| 803 | const afterExpand = await measureTimelineRow({ | |
| 804 | host: mounted.host, | |
| 805 | props, | |
| 806 | targetRowId: workEntries[0]!.id, | |
| 807 | }); | |
| 808 | expect( | |
| 809 | Math.abs(afterExpand.actualHeightPx - afterExpand.virtualizerSizePx), | |
| 810 | ).toBeLessThanOrEqual(8); | |
| 811 | } finally { | |
| 812 | await mounted.cleanup(); | |
| 813 | } | |
| 814 | }); | |
| 815 | ||
| 816 | it("preserves measured tail row heights when rows transition into virtualization", async () => { | |
| 817 | const beforeMessages = createFillerMessages({ | |
| 818 | prefix: "tail-transition-before", | |
| 819 | startOffsetSeconds: 0, | |
| 820 | pairCount: 1, | |
| 821 | }); | |
| 822 | const afterMessages = createFillerMessages({ | |
| 823 | prefix: "tail-transition-after", | |
| 824 | startOffsetSeconds: 40, | |
| 825 | pairCount: 3, | |
| 826 | }); | |
| 827 | const targetMessage = createMessage({ | |
| 828 | id: "target-tail-transition", | |
| 829 | role: "assistant", | |
| 830 | text: "Validation passed on the merged tree.", | |
| 831 | offsetSeconds: 12, | |
| 832 | }); | |
| 833 | let latestSnapshot: VirtualizerSnapshot | null = null; | |
| 834 | const initialProps = createBaseTimelineProps({ | |
| 835 | messages: [...beforeMessages, targetMessage, ...afterMessages], | |
| 836 | turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ | |
| 837 | { path: ".plans/effect-atom.md", additions: 89, deletions: 0 }, | |
| 838 | { | |
| 839 | path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts", | |
| 840 | additions: 4, | |
| 841 | deletions: 3, | |
| 842 | }, | |
| 843 | { | |
| 844 | path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts", | |
| 845 | additions: 131, | |
| 846 | deletions: 128, | |
| 847 | }, | |
| 848 | { | |
| 849 | path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts", | |
| 850 | additions: 1, | |
| 851 | deletions: 1, | |
| 852 | }, | |
| 853 | { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 }, | |
| 854 | { | |
| 855 | path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts", | |
| 856 | additions: 106, | |
| 857 | deletions: 112, | |
| 858 | }, | |
| 859 | { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 }, | |
| 860 | { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 }, | |
| 861 | { | |
| 862 | path: "apps/web/src/components/chat/MessagesTimeline.tsx", | |
| 863 | additions: 52, | |
| 864 | deletions: 7, | |
| 865 | }, | |
| 866 | { | |
| 867 | path: "apps/web/src/components/chat/ChangedFilesTree.tsx", | |
| 868 | additions: 32, | |
| 869 | deletions: 4, | |
| 870 | }, | |
| 871 | { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, | |
| 872 | { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, | |
| 873 | ]), | |
| 874 | onVirtualizerSnapshot: (snapshot) => { | |
| 875 | latestSnapshot = { | |
| 876 | totalSize: snapshot.totalSize, | |
| 877 | measurements: snapshot.measurements, | |
| 878 | }; | |
| 879 | }, | |
| 880 | }); | |
| 881 | ||
| 882 | const mounted = await mountMessagesTimeline({ props: initialProps }); | |
| 883 | ||
| 884 | try { | |
| 885 | const initiallyRenderedHeight = await measureRenderedRowActualHeight({ | |
| 886 | host: mounted.host, | |
| 887 | targetRowId: targetMessage.id, | |
| 888 | }); | |
| 889 | ||
| 890 | const appendedProps = createBaseTimelineProps({ | |
| 891 | messages: [ | |
| 892 | ...beforeMessages, | |
| 893 | targetMessage, | |
| 894 | ...afterMessages, | |
| 895 | ...createFillerMessages({ | |
| 896 | prefix: "tail-transition-extra", | |
| 897 | startOffsetSeconds: 120, | |
| 898 | pairCount: 8, | |
| 899 | }), | |
| 900 | ], | |
| 901 | turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId, | |
| 902 | onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, | |
| 903 | }); | |
| 904 | await mounted.rerender(appendedProps); | |
| 905 | ||
| 906 | const scrollContainer = await waitForElement( | |
| 907 | () => | |
| 908 | mounted.host.querySelector<HTMLDivElement>( | |
| 909 | '[data-testid="messages-timeline-scroll-container"]', | |
| 910 | ), | |
| 911 | "Unable to find MessagesTimeline scroll container.", | |
| 912 | ); | |
| 913 | scrollContainer.scrollTop = scrollContainer.scrollHeight; | |
| 914 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 915 | await waitForLayout(); | |
| 916 | ||
| 917 | await vi.waitFor( | |
| 918 | () => { | |
| 919 | const measurement = latestSnapshot?.measurements.find( | |
| 920 | (entry) => entry.id === targetMessage.id, | |
| 921 | ); | |
| 922 | expect( | |
| 923 | measurement, | |
| 924 | "Expected target row to transition into virtualizer cache.", | |
| 925 | ).toBeTruthy(); | |
| 926 | expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( | |
| 927 | 8, | |
| 928 | ); | |
| 929 | }, | |
| 930 | { timeout: 8_000, interval: 16 }, | |
| 931 | ); | |
| 932 | } finally { | |
| 933 | await mounted.cleanup(); | |
| 934 | } | |
| 935 | }); | |
| 936 | ||
| 937 | it("preserves measured tail image row heights when rows transition into virtualization", async () => { | |
| 938 | const beforeMessages = createFillerMessages({ | |
| 939 | prefix: "tail-image-before", | |
| 940 | startOffsetSeconds: 0, | |
| 941 | pairCount: 1, | |
| 942 | }); | |
| 943 | const afterMessages = createFillerMessages({ | |
| 944 | prefix: "tail-image-after", | |
| 945 | startOffsetSeconds: 40, | |
| 946 | pairCount: 3, | |
| 947 | }); | |
| 948 | const targetMessage = createMessage({ | |
| 949 | id: "target-tail-image-transition", | |
| 950 | role: "user", | |
| 951 | text: "Here is a narrow screenshot.", | |
| 952 | offsetSeconds: 12, | |
| 953 | attachments: [ | |
| 954 | { | |
| 955 | type: "image", | |
| 956 | id: "target-tail-image", | |
| 957 | name: "narrow.svg", | |
| 958 | mimeType: "image/svg+xml", | |
| 959 | sizeBytes: 512, | |
| 960 | previewUrl: | |
| 961 | "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='72'%3E%3Crect width='240' height='72' fill='%23dbeafe'/%3E%3C/svg%3E", | |
| 962 | }, | |
| 963 | ], | |
| 964 | }); | |
| 965 | let latestSnapshot: VirtualizerSnapshot | null = null; | |
| 966 | const initialProps = createBaseTimelineProps({ | |
| 967 | messages: [...beforeMessages, targetMessage, ...afterMessages], | |
| 968 | onVirtualizerSnapshot: (snapshot) => { | |
| 969 | latestSnapshot = { | |
| 970 | totalSize: snapshot.totalSize, | |
| 971 | measurements: snapshot.measurements, | |
| 972 | }; | |
| 973 | }, | |
| 974 | }); | |
| 975 | const mounted = await mountMessagesTimeline({ props: initialProps }); | |
| 976 | ||
| 977 | try { | |
| 978 | await vi.waitFor( | |
| 979 | () => { | |
| 980 | const image = mounted.host.querySelector<HTMLImageElement>( | |
| 981 | `[data-timeline-row-id="${targetMessage.id}"] img`, | |
| 982 | ); | |
| 983 | expect(image?.naturalHeight ?? 0).toBeGreaterThan(0); | |
| 984 | }, | |
| 985 | { timeout: 8_000, interval: 16 }, | |
| 986 | ); | |
| 987 | ||
| 988 | const initiallyRenderedHeight = await measureRenderedRowActualHeight({ | |
| 989 | host: mounted.host, | |
| 990 | targetRowId: targetMessage.id, | |
| 991 | }); | |
| 992 | const appendedProps = createBaseTimelineProps({ | |
| 993 | messages: [ | |
| 994 | ...beforeMessages, | |
| 995 | targetMessage, | |
| 996 | ...afterMessages, | |
| 997 | ...createFillerMessages({ | |
| 998 | prefix: "tail-image-extra", | |
| 999 | startOffsetSeconds: 120, | |
| 1000 | pairCount: 8, | |
| 1001 | }), | |
| 1002 | ], | |
| 1003 | onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, | |
| 1004 | }); | |
| 1005 | await mounted.rerender(appendedProps); | |
| 1006 | ||
| 1007 | const scrollContainer = await waitForElement( | |
| 1008 | () => | |
| 1009 | mounted.host.querySelector<HTMLDivElement>( | |
| 1010 | '[data-testid="messages-timeline-scroll-container"]', | |
| 1011 | ), | |
| 1012 | "Unable to find MessagesTimeline scroll container.", | |
| 1013 | ); | |
| 1014 | scrollContainer.scrollTop = scrollContainer.scrollHeight; | |
| 1015 | scrollContainer.dispatchEvent(new Event("scroll")); | |
| 1016 | await waitForLayout(); | |
| 1017 | ||
| 1018 | await vi.waitFor( | |
| 1019 | () => { | |
| 1020 | const measurement = latestSnapshot?.measurements.find( | |
| 1021 | (entry) => entry.id === targetMessage.id, | |
| 1022 | ); | |
| 1023 | expect( | |
| 1024 | measurement, | |
| 1025 | "Expected target image row to transition into virtualizer cache.", | |
| 1026 | ).toBeTruthy(); | |
| 1027 | expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( | |
| 1028 | 8, | |
| 1029 | ); | |
| 1030 | }, | |
| 1031 | { timeout: 8_000, interval: 16 }, | |
| 1032 | ); | |
| 1033 | } finally { | |
| 1034 | await mounted.cleanup(); | |
| 1035 | } | |
| 1036 | }); | |
| 1037 | }); |
apps/web/src/components/timelineHeight.test.ts+4-4| ... | ... | @@ -11,7 +11,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 11 | 11 | role: "assistant", |
| 12 | 12 | text: "a".repeat(144), |
| 13 | 13 | }), |
| 14 | ).toBe(122); | |
| 14 | ).toBe(86.5); | |
| 15 | 15 | }); |
| 16 | 16 | |
| 17 | 17 | it("uses assistant sizing rules for system messages", () => { |
| ... | ... | @@ -20,7 +20,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 20 | 20 | role: "system", |
| 21 | 21 | text: "a".repeat(144), |
| 22 | 22 | }), |
| 23 | ).toBe(122); | |
| 23 | ).toBe(86.5); | |
| 24 | 24 | }); |
| 25 | 25 | |
| 26 | 26 | it("adds one attachment row for one or two user attachments", () => { |
| ... | ... | @@ -132,7 +132,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 132 | 132 | text: "a".repeat(200), |
| 133 | 133 | }; |
| 134 | 134 | |
| 135 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); | |
| 136 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); | |
| 135 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75); | |
| 136 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5); | |
| 137 | 137 | }); |
| 138 | 138 | }); |
apps/web/src/components/timelineHeight.ts+9-5| ... | ... | @@ -3,8 +3,12 @@ import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContex |
| 3 | 3 | |
| 4 | 4 | const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; |
| 5 | 5 | const USER_CHARS_PER_LINE_FALLBACK = 56; |
| 6 | const LINE_HEIGHT_PX = 22; | |
| 7 | const ASSISTANT_BASE_HEIGHT_PX = 78; | |
| 6 | const USER_LINE_HEIGHT_PX = 22; | |
| 7 | const ASSISTANT_LINE_HEIGHT_PX = 22.75; | |
| 8 | // Assistant rows render as markdown content plus a compact timestamp meta line. | |
| 9 | // The DOM baseline is much smaller than the user bubble chrome, so model it | |
| 10 | // separately instead of reusing the old shared constant. | |
| 11 | const ASSISTANT_BASE_HEIGHT_PX = 41; | |
| 8 | 12 | const USER_BASE_HEIGHT_PX = 96; |
| 9 | 13 | const ATTACHMENTS_PER_ROW = 2; |
| 10 | 14 | // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. |
| ... | ... | @@ -73,7 +77,7 @@ export function estimateTimelineMessageHeight( |
| 73 | 77 | if (message.role === "assistant") { |
| 74 | 78 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); |
| 75 | 79 | const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); |
| 76 | return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; | |
| 80 | return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; | |
| 77 | 81 | } |
| 78 | 82 | |
| 79 | 83 | if (message.role === "user") { |
| ... | ... | @@ -92,12 +96,12 @@ export function estimateTimelineMessageHeight( |
| 92 | 96 | const attachmentCount = message.attachments?.length ?? 0; |
| 93 | 97 | const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); |
| 94 | 98 | const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; |
| 95 | return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; | |
| 99 | return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight; | |
| 96 | 100 | } |
| 97 | 101 | |
| 98 | 102 | // `system` messages are not rendered in the chat timeline, but keep a stable |
| 99 | 103 | // explicit branch in case they are present in timeline data. |
| 100 | 104 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); |
| 101 | 105 | const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); |
| 102 | return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; | |
| 106 | return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; | |
| 103 | 107 | } |
apps/web/src/routes/__root.tsx+9-4| ... | ... | @@ -21,6 +21,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component |
| 21 | 21 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 22 | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | 23 | import { |
| 24 | getServerConfigUpdatedNotification, | |
| 24 | 25 | type ServerConfigUpdateSource, |
| 25 | 26 | useServerConfig, |
| 26 | 27 | useServerConfigUpdatedSubscription, |
| ... | ... | @@ -206,7 +207,7 @@ function EventRouter() { |
| 206 | 207 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 207 | 208 | const pathnameRef = useRef(pathname); |
| 208 | 209 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 209 | const handledConfigReplayRef = useRef(false); | |
| 210 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); | |
| 210 | 211 | const disposedRef = useRef(false); |
| 211 | 212 | const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined); |
| 212 | 213 | const serverConfig = useServerConfig(); |
| ... | ... | @@ -243,15 +244,19 @@ function EventRouter() { |
| 243 | 244 | |
| 244 | 245 | const handleServerConfigUpdated = useEffectEvent( |
| 245 | 246 | ({ |
| 247 | id, | |
| 246 | 248 | payload, |
| 247 | 249 | source, |
| 248 | 250 | }: { |
| 251 | readonly id: number; | |
| 249 | 252 | readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload; |
| 250 | 253 | readonly source: ServerConfigUpdateSource; |
| 251 | 254 | }) => { |
| 252 | const isReplay = !handledConfigReplayRef.current; | |
| 253 | handledConfigReplayRef.current = true; | |
| 254 | if (isReplay || source !== "keybindingsUpdated") { | |
| 255 | if (id <= seenServerConfigUpdateIdRef.current) { | |
| 256 | return; | |
| 257 | } | |
| 258 | seenServerConfigUpdateIdRef.current = id; | |
| 259 | if (source !== "keybindingsUpdated") { | |
| 255 | 260 | return; |
| 256 | 261 | } |
| 257 | 262 |
apps/web/src/rpc/serverState.ts+13-1| ... | ... | @@ -18,6 +18,7 @@ import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; |
| 18 | 18 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; |
| 19 | 19 | |
| 20 | 20 | export interface ServerConfigUpdatedNotification { |
| 21 | readonly id: number; | |
| 21 | 22 | readonly payload: ServerConfigUpdatedPayload; |
| 22 | 23 | readonly source: ServerConfigUpdateSource; |
| 23 | 24 | } |
| ... | ... | @@ -71,6 +72,10 @@ export function getServerConfig(): ServerConfig | null { |
| 71 | 72 | return appAtomRegistry.get(serverConfigAtom); |
| 72 | 73 | } |
| 73 | 74 | |
| 75 | export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null { | |
| 76 | return appAtomRegistry.get(serverConfigUpdatedAtom); | |
| 77 | } | |
| 78 | ||
| 74 | 79 | export function setServerConfigSnapshot(config: ServerConfig): void { |
| 75 | 80 | resolveServerConfig(config); |
| 76 | 81 | emitProvidersUpdated({ providers: config.providers }); |
| ... | ... | @@ -194,8 +199,11 @@ export function startServerStateSync(client: ServerStateClient): () => void { |
| 194 | 199 | |
| 195 | 200 | export function resetServerStateForTests() { |
| 196 | 201 | resetAppAtomRegistryForTests(); |
| 202 | nextServerConfigUpdatedNotificationId = 1; | |
| 197 | 203 | } |
| 198 | 204 | |
| 205 | let nextServerConfigUpdatedNotificationId = 1; | |
| 206 | ||
| 199 | 207 | function resolveServerConfig(config: ServerConfig): void { |
| 200 | 208 | appAtomRegistry.set(serverConfigAtom, config); |
| 201 | 209 | } |
| ... | ... | @@ -208,7 +216,11 @@ function emitServerConfigUpdated( |
| 208 | 216 | payload: ServerConfigUpdatedPayload, |
| 209 | 217 | source: ServerConfigUpdateSource, |
| 210 | 218 | ): void { |
| 211 | appAtomRegistry.set(serverConfigUpdatedAtom, { payload, source }); | |
| 219 | appAtomRegistry.set(serverConfigUpdatedAtom, { | |
| 220 | id: nextServerConfigUpdatedNotificationId++, | |
| 221 | payload, | |
| 222 | source, | |
| 223 | }); | |
| 212 | 224 | } |
| 213 | 225 | |
| 214 | 226 | function subscribeLatest<A>( |