| 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,5 +1,5 @@ |
| 1 | import { type TurnId } from "@t3tools/contracts"; | 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 | import { type TurnDiffFileChange } from "../../types"; | 3 | import { type TurnDiffFileChange } from "../../types"; |
| 4 | import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; | 4 | import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; |
| 5 | import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; | 5 | import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; |
| ... | @@ -7,6 +7,8 @@ import { cn } from "~/lib/utils"; | ... | @@ -7,6 +7,8 @@ import { cn } from "~/lib/utils"; |
| 7 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | 7 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 8 | import { VscodeEntryIcon } from "./VscodeEntryIcon"; | 8 | import { VscodeEntryIcon } from "./VscodeEntryIcon"; |
| 9 | 9 | ||
| 10 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; | ||
| 11 | |||
| 10 | export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | 12 | export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 11 | turnId: TurnId; | 13 | turnId: TurnId; |
| 12 | files: ReadonlyArray<TurnDiffFileChange>; | 14 | files: ReadonlyArray<TurnDiffFileChange>; |
| ... | @@ -20,32 +22,39 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -20,32 +22,39 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 20 | () => collectDirectoryPaths(treeNodes).join("\u0000"), | 22 | () => collectDirectoryPaths(treeNodes).join("\u0000"), |
| 21 | [treeNodes], | 23 | [treeNodes], |
| 22 | ); | 24 | ); |
| 23 | const allDirectoryExpansionState = useMemo( | 25 | const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`; |
| 24 | () => | 26 | const [directoryExpansionState, setDirectoryExpansionState] = useState<{ |
| 25 | buildDirectoryExpansionState( | 27 | key: string; |
| 26 | directoryPathsKey ? directoryPathsKey.split("\u0000") : [], | 28 | overrides: Record<string, boolean>; |
| 27 | allDirectoriesExpanded, | 29 | }>(() => ({ |
| 28 | ), | 30 | key: expansionStateKey, |
| 29 | [allDirectoriesExpanded, directoryPathsKey], | 31 | overrides: {}, |
| 30 | ); | 32 | })); |
| 31 | const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(() => | 33 | const expandedDirectories = |
| 32 | buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), | 34 | directoryExpansionState.key === expansionStateKey |
| 33 | ); | 35 | ? directoryExpansionState.overrides |
| 34 | useEffect(() => { | 36 | : EMPTY_DIRECTORY_OVERRIDES; |
| 35 | setExpandedDirectories(allDirectoryExpansionState); | ||
| 36 | }, [allDirectoryExpansionState]); | ||
| 37 | 37 | ||
| 38 | const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { | 38 | const toggleDirectory = useCallback( |
| 39 | setExpandedDirectories((current) => ({ | 39 | (pathValue: string) => { |
| 40 | ...current, | 40 | setDirectoryExpansionState((current) => { |
| 41 | [pathValue]: !(current[pathValue] ?? fallbackExpanded), | 41 | const nextOverrides = current.key === expansionStateKey ? current.overrides : {}; |
| 42 | })); | 42 | return { |
| 43 | }, []); | 43 | key: expansionStateKey, |
| 44 | overrides: { | ||
| 45 | ...nextOverrides, | ||
| 46 | [pathValue]: !(nextOverrides[pathValue] ?? allDirectoriesExpanded), | ||
| 47 | }, | ||
| 48 | }; | ||
| 49 | }); | ||
| 50 | }, | ||
| 51 | [allDirectoriesExpanded, expansionStateKey], | ||
| 52 | ); | ||
| 44 | 53 | ||
| 45 | const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { | 54 | const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { |
| 46 | const leftPadding = 8 + depth * 14; | 55 | const leftPadding = 8 + depth * 14; |
| 47 | if (node.kind === "directory") { | 56 | if (node.kind === "directory") { |
| 48 | const isExpanded = expandedDirectories[node.path] ?? depth === 0; | 57 | const isExpanded = expandedDirectories[node.path] ?? allDirectoriesExpanded; |
| 49 | return ( | 58 | return ( |
| 50 | <div key={`dir:${node.path}`}> | 59 | <div key={`dir:${node.path}`}> |
| 51 | <button | 60 | <button |
| ... | @@ -53,7 +62,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -53,7 +62,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 53 | data-scroll-anchor-ignore | 62 | data-scroll-anchor-ignore |
| 54 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" | 63 | className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80" |
| 55 | style={{ paddingLeft: `${leftPadding}px` }} | 64 | style={{ paddingLeft: `${leftPadding}px` }} |
| 56 | onClick={() => toggleDirectory(node.path, depth === 0)} | 65 | onClick={() => toggleDirectory(node.path)} |
| 57 | > | 66 | > |
| 58 | <ChevronRightIcon | 67 | <ChevronRightIcon |
| 59 | aria-hidden="true" | 68 | aria-hidden="true" |
| ... | @@ -124,14 +133,3 @@ function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] | ... | @@ -124,14 +133,3 @@ function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] |
| 124 | } | 133 | } |
| 125 | return paths; | 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,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 | export interface TimelineDurationMessage { | 9 | export interface TimelineDurationMessage { |
| 2 | id: string; | 10 | id: string; |
| 3 | role: "user" | "assistant" | "system"; | 11 | role: "user" | "assistant" | "system"; |
| ... | @@ -5,6 +13,29 @@ export interface TimelineDurationMessage { | ... | @@ -5,6 +13,29 @@ export interface TimelineDurationMessage { |
| 5 | completedAt?: string | undefined; | 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 | export function computeMessageDurationStart( | 39 | export function computeMessageDurationStart( |
| 9 | messages: ReadonlyArray<TimelineDurationMessage>, | 40 | messages: ReadonlyArray<TimelineDurationMessage>, |
| 10 | ): Map<string, string> { | 41 | ): Map<string, string> { |
| ... | @@ -27,3 +58,142 @@ export function computeMessageDurationStart( | ... | @@ -27,3 +58,142 @@ export function computeMessageDurationStart( |
| 27 | export function normalizeCompactToolLabel(value: string): string { | 58 | export function normalizeCompactToolLabel(value: string): string { |
| 28 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | 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,13 +35,18 @@ import { |
| 35 | } from "lucide-react"; | 35 | } from "lucide-react"; |
| 36 | import { Button } from "../ui/button"; | 36 | import { Button } from "../ui/button"; |
| 37 | import { clamp } from "effect/Number"; | 37 | import { clamp } from "effect/Number"; |
| 38 | import { estimateTimelineMessageHeight } from "../timelineHeight"; | ||
| 39 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; | 38 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 40 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 39 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 41 | import { ChangedFilesTree } from "./ChangedFilesTree"; | 40 | import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 42 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | 41 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 43 | import { MessageCopyButton } from "./MessageCopyButton"; | 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 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | 50 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 46 | import { | 51 | import { |
| 47 | deriveDisplayedUserMessageState, | 52 | deriveDisplayedUserMessageState, |
| ... | @@ -56,7 +61,6 @@ import { | ... | @@ -56,7 +61,6 @@ import { |
| 56 | textContainsInlineTerminalContextLabels, | 61 | textContainsInlineTerminalContextLabels, |
| 57 | } from "./userMessageTerminalContexts"; | 62 | } from "./userMessageTerminalContexts"; |
| 58 | 63 | ||
| 59 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | ||
| 60 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; | 64 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; |
| 61 | 65 | ||
| 62 | interface MessagesTimelineProps { | 66 | interface MessagesTimelineProps { |
| ... | @@ -81,6 +85,17 @@ interface MessagesTimelineProps { | ... | @@ -81,6 +85,17 @@ interface MessagesTimelineProps { |
| 81 | resolvedTheme: "light" | "dark"; | 85 | resolvedTheme: "light" | "dark"; |
| 82 | timestampFormat: TimestampFormat; | 86 | timestampFormat: TimestampFormat; |
| 83 | workspaceRoot: string | undefined; | 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 | export const MessagesTimeline = memo(function MessagesTimeline({ | 101 | export const MessagesTimeline = memo(function MessagesTimeline({ |
| ... | @@ -105,6 +120,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -105,6 +120,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 105 | resolvedTheme, | 120 | resolvedTheme, |
| 106 | timestampFormat, | 121 | timestampFormat, |
| 107 | workspaceRoot, | 122 | workspaceRoot, |
| 123 | onVirtualizerSnapshot, | ||
| 108 | }: MessagesTimelineProps) { | 124 | }: MessagesTimelineProps) { |
| 109 | const timelineRootRef = useRef<HTMLDivElement | null>(null); | 125 | const timelineRootRef = useRef<HTMLDivElement | null>(null); |
| 110 | const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null); | 126 | const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null); |
| ... | @@ -134,70 +150,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -134,70 +150,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 134 | }; | 150 | }; |
| 135 | }, [hasMessages, isWorking]); | 151 | }, [hasMessages, isWorking]); |
| 136 | 152 | ||
| 137 | const rows = useMemo<TimelineRow[]>(() => { | 153 | const rows = useMemo( |
| 138 | const nextRows: TimelineRow[] = []; | 154 | () => |
| 139 | const durationStartByMessageId = computeMessageDurationStart( | 155 | deriveMessagesTimelineRows({ |
| 140 | timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), | 156 | timelineEntries, |
| 141 | ); | 157 | completionDividerBeforeEntryId, |
| 142 | 158 | isWorking, | |
| 143 | for (let index = 0; index < timelineEntries.length; index += 1) { | 159 | activeTurnStartedAt, |
| 144 | const timelineEntry = timelineEntries[index]; | 160 | }), |
| 145 | if (!timelineEntry) { | 161 | [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], |
| 146 | continue; | 162 | ); |
| 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]); | ||
| 201 | 163 | ||
| 202 | const firstUnvirtualizedRowIndex = useMemo(() => { | 164 | const firstUnvirtualizedRowIndex = useMemo(() => { |
| 203 | const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); | 165 | const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); |
| ... | @@ -241,19 +203,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -241,19 +203,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 241 | minimum: 0, | 203 | minimum: 0, |
| 242 | maximum: rows.length, | 204 | maximum: rows.length, |
| 243 | }); | 205 | }); |
| 206 | const virtualMeasurementScopeKey = | ||
| 207 | timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; | ||
| 244 | 208 | ||
| 245 | const rowVirtualizer = useVirtualizer({ | 209 | const rowVirtualizer = useVirtualizer({ |
| 246 | count: virtualizedRowCount, | 210 | count: virtualizedRowCount, |
| 247 | getScrollElement: () => scrollContainer, | 211 | getScrollElement: () => scrollContainer, |
| 248 | // Use stable row ids so virtual measurements do not leak across thread switches. | 212 | // Scope cached row measurements to the current timeline width so offscreen |
| 249 | getItemKey: (index: number) => rows[index]?.id ?? index, | 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 | estimateSize: (index: number) => { | 218 | estimateSize: (index: number) => { |
| 251 | const row = rows[index]; | 219 | const row = rows[index]; |
| 252 | if (!row) return 96; | 220 | if (!row) return 96; |
| 253 | if (row.kind === "work") return 112; | 221 | return estimateMessagesTimelineRowHeight(row, { |
| 254 | if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); | 222 | expandedWorkGroups, |
| 255 | if (row.kind === "working") return 40; | 223 | timelineWidthPx, |
| 256 | return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); | 224 | turnDiffSummaryByAssistantMessageId, |
| 225 | }); | ||
| 257 | }, | 226 | }, |
| 258 | measureElement: measureVirtualElement, | 227 | measureElement: measureVirtualElement, |
| 259 | useAnimationFrameWithResizeObserver: true, | 228 | useAnimationFrameWithResizeObserver: true, |
| ... | @@ -295,6 +264,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -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 | const virtualRows = rowVirtualizer.getVirtualItems(); | 294 | const virtualRows = rowVirtualizer.getVirtualItems(); |
| 300 | const nonVirtualizedRows = rows.slice(virtualizedRowCount); | 295 | const nonVirtualizedRows = rows.slice(virtualizedRowCount); |
| ... | @@ -311,6 +306,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -311,6 +306,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 311 | const renderRowContent = (row: TimelineRow) => ( | 306 | const renderRowContent = (row: TimelineRow) => ( |
| 312 | <div | 307 | <div |
| 313 | className="pb-4" | 308 | className="pb-4" |
| 309 | data-timeline-row-id={row.id} | ||
| 314 | data-timeline-row-kind={row.kind} | 310 | data-timeline-row-kind={row.kind} |
| 315 | data-message-id={row.kind === "message" ? row.message.id : undefined} | 311 | data-message-id={row.kind === "message" ? row.message.id : undefined} |
| 316 | data-message-role={row.kind === "message" ? row.message.role : undefined} | 312 | data-message-role={row.kind === "message" ? row.message.role : undefined} |
| ... | @@ -585,6 +581,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -585,6 +581,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 585 | <div | 581 | <div |
| 586 | key={`virtual-row:${row.id}`} | 582 | key={`virtual-row:${row.id}`} |
| 587 | data-index={virtualRow.index} | 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 | ref={rowVirtualizer.measureElement} | 588 | ref={rowVirtualizer.measureElement} |
| 589 | className="absolute left-0 top-0 w-full" | 589 | className="absolute left-0 top-0 w-full" |
| 590 | style={{ transform: `translateY(${virtualRow.start}px)` }} | 590 | style={{ transform: `translateY(${virtualRow.start}px)` }} |
| ... | @@ -605,35 +605,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -605,35 +605,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 605 | 605 | ||
| 606 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; | 606 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; |
| 607 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; | 607 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; |
| 608 | type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"]; | 608 | type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; |
| 609 | type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"]; | 609 | type TimelineRow = MessagesTimelineRow; |
| 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 | } | ||
| 637 | 610 | ||
| 638 | function formatWorkingTimer(startIso: string, endIso: string): string | null { | 611 | function formatWorkingTimer(startIso: string, endIso: string): string | null { |
| 639 | const startedAtMs = Date.parse(startIso); | 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,7 +11,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 11 | role: "assistant", | 11 | role: "assistant", |
| 12 | text: "a".repeat(144), | 12 | text: "a".repeat(144), |
| 13 | }), | 13 | }), |
| 14 | ).toBe(122); | 14 | ).toBe(86.5); |
| 15 | }); | 15 | }); |
| 16 | 16 | ||
| 17 | it("uses assistant sizing rules for system messages", () => { | 17 | it("uses assistant sizing rules for system messages", () => { |
| ... | @@ -20,7 +20,7 @@ describe("estimateTimelineMessageHeight", () => { | ... | @@ -20,7 +20,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 20 | role: "system", | 20 | role: "system", |
| 21 | text: "a".repeat(144), | 21 | text: "a".repeat(144), |
| 22 | }), | 22 | }), |
| 23 | ).toBe(122); | 23 | ).toBe(86.5); |
| 24 | }); | 24 | }); |
| 25 | 25 | ||
| 26 | it("adds one attachment row for one or two user attachments", () => { | 26 | it("adds one attachment row for one or two user attachments", () => { |
| ... | @@ -132,7 +132,7 @@ describe("estimateTimelineMessageHeight", () => { | ... | @@ -132,7 +132,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 132 | text: "a".repeat(200), | 132 | text: "a".repeat(200), |
| 133 | }; | 133 | }; |
| 134 | 134 | ||
| 135 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); | 135 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75); |
| 136 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); | 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,8 +3,12 @@ import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContex |
| 3 | 3 | ||
| 4 | const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; | 4 | const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; |
| 5 | const USER_CHARS_PER_LINE_FALLBACK = 56; | 5 | const USER_CHARS_PER_LINE_FALLBACK = 56; |
| 6 | const LINE_HEIGHT_PX = 22; | 6 | const USER_LINE_HEIGHT_PX = 22; |
| 7 | const ASSISTANT_BASE_HEIGHT_PX = 78; | 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 | const USER_BASE_HEIGHT_PX = 96; | 12 | const USER_BASE_HEIGHT_PX = 96; |
| 9 | const ATTACHMENTS_PER_ROW = 2; | 13 | const ATTACHMENTS_PER_ROW = 2; |
| 10 | // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. | 14 | // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. |
| ... | @@ -73,7 +77,7 @@ export function estimateTimelineMessageHeight( | ... | @@ -73,7 +77,7 @@ export function estimateTimelineMessageHeight( |
| 73 | if (message.role === "assistant") { | 77 | if (message.role === "assistant") { |
| 74 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); | 78 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); |
| 75 | const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); | 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 | if (message.role === "user") { | 83 | if (message.role === "user") { |
| ... | @@ -92,12 +96,12 @@ export function estimateTimelineMessageHeight( | ... | @@ -92,12 +96,12 @@ export function estimateTimelineMessageHeight( |
| 92 | const attachmentCount = message.attachments?.length ?? 0; | 96 | const attachmentCount = message.attachments?.length ?? 0; |
| 93 | const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); | 97 | const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); |
| 94 | const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; | 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 | // `system` messages are not rendered in the chat timeline, but keep a stable | 102 | // `system` messages are not rendered in the chat timeline, but keep a stable |
| 99 | // explicit branch in case they are present in timeline data. | 103 | // explicit branch in case they are present in timeline data. |
| 100 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); | 104 | const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); |
| 101 | const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); | 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,6 +21,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component |
| 21 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | 21 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; |
| 22 | import { readNativeApi } from "../nativeApi"; | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | import { | 23 | import { |
| 24 | getServerConfigUpdatedNotification, | ||
| 24 | type ServerConfigUpdateSource, | 25 | type ServerConfigUpdateSource, |
| 25 | useServerConfig, | 26 | useServerConfig, |
| 26 | useServerConfigUpdatedSubscription, | 27 | useServerConfigUpdatedSubscription, |
| ... | @@ -206,7 +207,7 @@ function EventRouter() { | ... | @@ -206,7 +207,7 @@ function EventRouter() { |
| 206 | const pathname = useLocation({ select: (loc) => loc.pathname }); | 207 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 207 | const pathnameRef = useRef(pathname); | 208 | const pathnameRef = useRef(pathname); |
| 208 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | 209 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 209 | const handledConfigReplayRef = useRef(false); | 210 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); |
| 210 | const disposedRef = useRef(false); | 211 | const disposedRef = useRef(false); |
| 211 | const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined); | 212 | const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined); |
| 212 | const serverConfig = useServerConfig(); | 213 | const serverConfig = useServerConfig(); |
| ... | @@ -243,15 +244,19 @@ function EventRouter() { | ... | @@ -243,15 +244,19 @@ function EventRouter() { |
| 243 | 244 | ||
| 244 | const handleServerConfigUpdated = useEffectEvent( | 245 | const handleServerConfigUpdated = useEffectEvent( |
| 245 | ({ | 246 | ({ |
| 247 | id, | ||
| 246 | payload, | 248 | payload, |
| 247 | source, | 249 | source, |
| 248 | }: { | 250 | }: { |
| 251 | readonly id: number; | ||
| 249 | readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload; | 252 | readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload; |
| 250 | readonly source: ServerConfigUpdateSource; | 253 | readonly source: ServerConfigUpdateSource; |
| 251 | }) => { | 254 | }) => { |
| 252 | const isReplay = !handledConfigReplayRef.current; | 255 | if (id <= seenServerConfigUpdateIdRef.current) { |
| 253 | handledConfigReplayRef.current = true; | 256 | return; |
| 254 | if (isReplay || source !== "keybindingsUpdated") { | 257 | } |
| 258 | seenServerConfigUpdateIdRef.current = id; | ||
| 259 | if (source !== "keybindingsUpdated") { | ||
| 255 | return; | 260 | return; |
| 256 | } | 261 | } |
| 257 | 262 |
apps/web/src/rpc/serverState.ts+13-1| ... | @@ -18,6 +18,7 @@ import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; | ... | @@ -18,6 +18,7 @@ import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; |
| 18 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; | 18 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; |
| 19 | 19 | ||
| 20 | export interface ServerConfigUpdatedNotification { | 20 | export interface ServerConfigUpdatedNotification { |
| 21 | readonly id: number; | ||
| 21 | readonly payload: ServerConfigUpdatedPayload; | 22 | readonly payload: ServerConfigUpdatedPayload; |
| 22 | readonly source: ServerConfigUpdateSource; | 23 | readonly source: ServerConfigUpdateSource; |
| 23 | } | 24 | } |
| ... | @@ -71,6 +72,10 @@ export function getServerConfig(): ServerConfig | null { | ... | @@ -71,6 +72,10 @@ export function getServerConfig(): ServerConfig | null { |
| 71 | return appAtomRegistry.get(serverConfigAtom); | 72 | return appAtomRegistry.get(serverConfigAtom); |
| 72 | } | 73 | } |
| 73 | 74 | ||
| 75 | export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null { | ||
| 76 | return appAtomRegistry.get(serverConfigUpdatedAtom); | ||
| 77 | } | ||
| 78 | |||
| 74 | export function setServerConfigSnapshot(config: ServerConfig): void { | 79 | export function setServerConfigSnapshot(config: ServerConfig): void { |
| 75 | resolveServerConfig(config); | 80 | resolveServerConfig(config); |
| 76 | emitProvidersUpdated({ providers: config.providers }); | 81 | emitProvidersUpdated({ providers: config.providers }); |
| ... | @@ -194,8 +199,11 @@ export function startServerStateSync(client: ServerStateClient): () => void { | ... | @@ -194,8 +199,11 @@ export function startServerStateSync(client: ServerStateClient): () => void { |
| 194 | 199 | ||
| 195 | export function resetServerStateForTests() { | 200 | export function resetServerStateForTests() { |
| 196 | resetAppAtomRegistryForTests(); | 201 | resetAppAtomRegistryForTests(); |
| 202 | nextServerConfigUpdatedNotificationId = 1; | ||
| 197 | } | 203 | } |
| 198 | 204 | ||
| 205 | let nextServerConfigUpdatedNotificationId = 1; | ||
| 206 | |||
| 199 | function resolveServerConfig(config: ServerConfig): void { | 207 | function resolveServerConfig(config: ServerConfig): void { |
| 200 | appAtomRegistry.set(serverConfigAtom, config); | 208 | appAtomRegistry.set(serverConfigAtom, config); |
| 201 | } | 209 | } |
| ... | @@ -208,7 +216,11 @@ function emitServerConfigUpdated( | ... | @@ -208,7 +216,11 @@ function emitServerConfigUpdated( |
| 208 | payload: ServerConfigUpdatedPayload, | 216 | payload: ServerConfigUpdatedPayload, |
| 209 | source: ServerConfigUpdateSource, | 217 | source: ServerConfigUpdateSource, |
| 210 | ): void { | 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 | function subscribeLatest<A>( | 226 | function subscribeLatest<A>( |