From ae4070f6e99c38b3255299b7143a599ee28a7f0f Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 06:30:26 +0800 Subject: [PATCH] feat(components): render the conversation stream from ConversationView in O(window) (phase 1b-B) `SessionChatStream` now takes `conversationView` (plus the owner's explicit `fallbackHistory` array on the full-Mirror rollback path) and never reads `sessionDoc.history`; a structural test fails if that spelling returns anywhere under `components/ai-gui`. - `buildChatStreamItemsFromView` emits one item per turn: a message item where the turn is hydrated and a `placeholder` item from the index row elsewhere. Both carry `turnIndex`, and a placeholder uses the entry id as its Virtua key so hydration swaps content under a stable key. Empty assistant turns are dropped from `itemCount`/`planCount`, ids are de-duplicated, and the last-assistant ids come from index rows. - `TurnPlaceholderRow` renders role, time, `summary.headText` and activity counts when present; its `minHeight` is `estimateTurnHeightPx` (summary prose/activity, else item count, else a role constant), which Virtua measures as the row's real size. - The view reports the visible turn window from Virtua's offset math after the initial scroll restore; `computeHydrationRange` adds two spans on each side, snapped to a quantum, and `useTurnRange` retains + hydrates it and re-renders per frame of view changes. An active in-conversation search hydrates every turn (documented as temporary). - Outline entries read placeholders' `summary.headText`/`textChars`; a round without a summary keeps the untitled fallback until the rail's new `onHoverRound` hydrates it. `scrollToIndex` takes a turn index and rows resolve through `row.turnIndex`. - `ExtremeConversationView` story: 3,000 synthetic turns through a Loro doc and a real `ConversationView` (scroll, outline jump, expansion). Model: claude-fable-5-1 --- locales/en.json | 4 + locales/zh_CN.json | 4 + .../src/components/ai-gui/AGENTS.md | 42 ++- .../ai-gui/build-chat-stream-items.ts | 246 ++++++++++++------ .../ai-gui/conversation-outline-rail.tsx | 11 + .../src/components/ai-gui/index.tsx | 90 ++++++- .../ai-gui/turn-placeholder-estimate.ts | 45 ++++ .../ai-gui/turn-placeholder-row.tsx | 73 ++++++ .../components/src/components/ai-gui/view.tsx | 159 ++++++++++- .../sessions/session-chat-interface.tsx | 13 +- .../components/src/hooks/use-turn-range.ts | 37 +++ .../src/lib/conversation-outline.ts | 63 ++++- .../lib/conversation-view/hydration-range.ts | 46 ++++ .../ConversationOutlineRail.stories.tsx | 162 +++++++++++- .../ai-gui-reads-history-through-view.test.ts | 41 +++ .../tests/build-chat-stream-items.test.ts | 104 +++++++- .../tests/chat-virtual-rows-identity.test.ts | 27 ++ .../tests/conversation-outline.test.ts | 53 ++++ .../components/tests/hydration-range.test.ts | 76 ++++++ .../tests/turn-placeholder-estimate.test.ts | 39 +++ 20 files changed, 1212 insertions(+), 123 deletions(-) create mode 100644 packages/components/src/components/ai-gui/turn-placeholder-estimate.ts create mode 100644 packages/components/src/components/ai-gui/turn-placeholder-row.tsx create mode 100644 packages/components/src/hooks/use-turn-range.ts create mode 100644 packages/components/src/lib/conversation-view/hydration-range.ts create mode 100644 packages/components/tests/ai-gui-reads-history-through-view.test.ts create mode 100644 packages/components/tests/hydration-range.test.ts create mode 100644 packages/components/tests/turn-placeholder-estimate.test.ts diff --git a/locales/en.json b/locales/en.json index 806b488f0..1abc2d837 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1603,6 +1603,10 @@ "sessions.toolActivity.tools_other": "Called {{count}} tools", "sessions.toolActivity.thinking": "Thinking…", "sessions.toolActivity.thought": "Thought", + "sessions.turnPlaceholder.user": "You", + "sessions.turnPlaceholder.assistant": "Agent", + "sessions.turnPlaceholder.system": "System", + "sessions.turnPlaceholder.loading": "Loading turn", "sessions.prCi.failing": "CI failed", "sessions.prCi.expected": "CI expected", "sessions.prCi.label": "CI checks", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 0dadfde88..b0eb93533 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1603,6 +1603,10 @@ "sessions.toolActivity.tools_other": "调用了 {{count}} 个工具", "sessions.toolActivity.thinking": "思考中…", "sessions.toolActivity.thought": "思考过程", + "sessions.turnPlaceholder.user": "你", + "sessions.turnPlaceholder.assistant": "智能体", + "sessions.turnPlaceholder.system": "系统", + "sessions.turnPlaceholder.loading": "正在加载轮次", "sessions.prCi.failing": "CI 未通过", "sessions.prCi.expected": "等待 CI 上报", "sessions.prCi.label": "CI 检查", diff --git a/packages/components/src/components/ai-gui/AGENTS.md b/packages/components/src/components/ai-gui/AGENTS.md index 7f3b00e45..15bbb7bdc 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -8,6 +8,38 @@ | Turns | `assistant-turn-render-blocks.ts` | Activity groups and foldable segments. | | Outline | `conversation-outline-*` | Round ticks and navigation. | +## History Source + +- The renderer reads history ONLY through `ConversationView` + (`lib/conversation-view`): `SessionChatStream` takes `conversationView` + and, on the full-Mirror rollback path, the owner's explicit + `fallbackHistory` array. Nothing under this directory reads + `sessionDoc.history` / `doc.history`; on the view path that getter + materializes the whole transcript. Pinned by + `tests/ai-gui-reads-history-through-view.test.ts`. +- `buildChatStreamItemsFromView` emits one item per turn: a message item where + the turn is hydrated, a `placeholder` item (index row) elsewhere. Both carry + `turnIndex`, and a placeholder uses the ENTRY ID as its Virtua key so + hydration swaps content under a stable key. `TurnPlaceholderRow` renders + role, time, `summary.headText` and activity counts when present; its + `minHeight` is `estimateTurnHeightPx` (summary `textChars`/activity, else + `itemCount`, else a role constant), which Virtua then measures as real. +- The hydration window is `computeHydrationRange` over the visible turn + range the view reports (`onVisibleTurnRangeChange`, from Virtua's offset + math after the initial scroll restore): the viewport plus two spans each + side, snapped to `HYDRATION_RANGE_QUANTUM`; `useTurnRange` retains and + hydrates it and re-renders per frame of view changes. The tail stays + hydrated by the view itself; `lastAssistantMessageId` / + `lastCompletedAssistantMessageId` come from index rows. +- Outline entries come from index rows for placeholders (`summary.headText` + as title/preview, `textChars + thoughtChars` as weight); a round without + a summary shows the untitled fallback until the rail's `onHoverRound` + hydrates it (`onOutlineHoverTurn`). An active in-conversation search + hydrates every turn so matched rows exist — a temporary bridge until the + search index reads through the view. +- `SessionChatStreamHandle.scrollToIndex` takes a TURN index; rows resolve + through `row.turnIndex`, never the item position. + ## Stream And Search - In-conversation search indexes prose only: user/assistant text, thinking, and @@ -21,9 +53,10 @@ history indexes must translate to the matching virtual child row. - Keep Virtua `shift={false}`; stale cumulative heights otherwise overlap rows. `bufferSize` trades fast-scroll blanks against retaining resizing rows. -- `buildChatStreamItems()` drops empty assistant entries (a `null` render cannot - be measured) and de-duplicates history ids (duplicate Virtua keys desync the - list). See `tests/build-chat-stream-items.test.ts`. +- `buildChatStreamItems()` / `buildChatStreamItemsFromView()` drop empty + assistant entries (a `null` render cannot be measured; placeholders decide + from `itemCount`/`planCount`) and de-duplicate history ids (duplicate Virtua + keys desync the list). See `tests/build-chat-stream-items.test.ts`. - `leadingContent` is a real first row. Include it in sticky counts and every scroll target; never overlay or persist it. A `session_create` completion renders one card per successful target and reads only that target's title. @@ -100,7 +133,8 @@ work) and a hover preview. `OUTLINE_ANCHOR_TOLERANCE_PX` greater than jump tolerance. - Follow-output suppression is owned by `pendingOutlineJumpRef`, not a render; React may skip the commit when clicking the already-active round. -- Coverage: `tests/conversation-outline*.test.ts` and `ExtremeConversation`. +- Coverage: `tests/conversation-outline*.test.ts`, `ExtremeConversation` and + the view-backed `ExtremeConversationView` (3,000 turns through a Loro doc). ## Content Contracts diff --git a/packages/components/src/components/ai-gui/build-chat-stream-items.ts b/packages/components/src/components/ai-gui/build-chat-stream-items.ts index 88e42912c..7b6df6625 100644 --- a/packages/components/src/components/ai-gui/build-chat-stream-items.ts +++ b/packages/components/src/components/ai-gui/build-chat-stream-items.ts @@ -1,5 +1,6 @@ import type { MessageContent, SessionHistory, SessionHistoryParsed, SessionId } from '@lody/shared'; -import type { ChatStreamItem } from './view'; +import type { ConversationView, TurnIndexRow } from '@/lib/conversation-view'; +import type { ChatStreamItem, SessionMessageItem, TurnPlaceholderItem } from './view'; import { normalizeMessageContent } from './message-content-guards'; export type BuildChatStreamItemsCache = ReadonlyMap; @@ -12,7 +13,7 @@ export type BuildChatStreamItemsResult = { }; type CachedChatStreamMessageItem = { - readonly item: ChatStreamItem & { type: 'message' }; + readonly item: SessionMessageItem; readonly rawEntry: SessionHistory; readonly rawAcpTurnId: unknown; readonly rawItems: unknown; @@ -42,16 +43,22 @@ const isEmptyAssistantMessage = (message: SessionHistoryParsed): boolean => !message.items.length && !(message.plan && message.plan.length > 0); +/** The same rule read from an index row, for a turn that is not hydrated. */ +const isEmptyAssistantRow = (row: TurnIndexRow): boolean => + row.role === 'assistant' && (row.itemCount ?? 0) === 0 && (row.planCount ?? 0) === 0; + function canReuseCachedMessageItem( cached: CachedChatStreamMessageItem | undefined, entry: SessionHistory, sessionId: SessionId, + turnIndex: number, /** Resolved config we would attach to this message (user's own or inherited). */ expectedInputConfig: SessionHistoryParsed['inputConfig'] ): cached is CachedChatStreamMessageItem { return ( cached !== undefined && cached.item.sessionId === sessionId && + cached.item.turnIndex === turnIndex && cached.rawEntry === entry && cached.rawAcpTurnId === entry.acpTurnId && cached.rawItems === entry.items && @@ -73,10 +80,11 @@ function canReuseCachedMessageItem( function createCachedMessageItem( entry: SessionHistory, sessionId: SessionId, + turnIndex: number, message: SessionHistoryParsed ): CachedChatStreamMessageItem { return { - item: { type: 'message', sessionId, message }, + item: { type: 'message', sessionId, turnIndex, message }, rawEntry: entry, rawAcpTurnId: entry.acpTurnId, rawItems: entry.items, @@ -88,6 +96,125 @@ function createCachedMessageItem( }; } +/** + * Placeholders are keyed by index ROW: `ConversationView` hands out the same + * row object until the turn changes, so an unchanged placeholder keeps its + * identity across rebuilds and the memoized row components stay quiet. + */ +const placeholderByRow = new WeakMap(); + +const placeholderItem = ( + row: TurnIndexRow, + sessionId: SessionId, + turnIndex: number +): TurnPlaceholderItem => { + const cached = placeholderByRow.get(row); + if (cached && cached.sessionId === sessionId && cached.turnIndex === turnIndex) return cached; + const item: TurnPlaceholderItem = { type: 'placeholder', sessionId, turnIndex, row }; + placeholderByRow.set(row, item); + return item; +}; + +type Builder = { + items: ChatStreamItem[]; + seenIds: Set; + cache: Map; + previousCache: BuildChatStreamItemsCache | undefined; + sessionId: SessionId; + lastAssistantMessageId: string | null; + lastCompletedAssistantMessageId: string | null; + /** Config from the latest user turn — attached to the following assistant + * so the model meta row can show the full turn run-config on demand. */ + lastUserInputConfig: SessionHistoryParsed['inputConfig'] | undefined; +}; + +const createBuilder = ( + sessionId: SessionId, + previousCache: BuildChatStreamItemsCache | undefined +): Builder => ({ + items: [], + seenIds: new Set(), + cache: new Map(), + previousCache, + sessionId, + lastAssistantMessageId: null, + lastCompletedAssistantMessageId: null, + lastUserInputConfig: undefined, +}); + +const noteAssistant = (builder: Builder, id: string, finished: boolean | undefined): void => { + builder.lastAssistantMessageId = id; + if (finished === true) builder.lastCompletedAssistantMessageId = id; +}; + +const pushHydratedEntry = (builder: Builder, entry: SessionHistory, turnIndex: number): void => { + if (entry.role === 'user' && entry.inputConfig) { + builder.lastUserInputConfig = entry.inputConfig; + } + + const expectedInputConfig = + entry.role === 'user' + ? entry.inputConfig + : entry.role === 'assistant' + ? (entry.inputConfig ?? builder.lastUserInputConfig) + : entry.inputConfig; + + const cached = builder.previousCache?.get(entry.id); + if (canReuseCachedMessageItem(cached, entry, builder.sessionId, turnIndex, expectedInputConfig)) { + if (builder.seenIds.has(entry.id)) return; + builder.seenIds.add(entry.id); + if (entry.role === 'assistant') noteAssistant(builder, entry.id, entry.finished); + builder.cache.set(entry.id, cached); + builder.items.push(cached.item); + return; + } + + const message: SessionHistoryParsed = { + id: entry.id, + items: parseHistoryItemsForRender(entry.items), + role: entry.role, + status: entry.status, + read: entry.read ?? false, + timestamp: entry.timestamp, + endedAt: entry.endedAt, + userId: entry.userId, + acpTurnId: entry.acpTurnId, + modelInfo: entry.modelInfo, + fileDiff: entry.fileDiff, + finished: entry.finished, + plan: entry.plan, + // User turns keep their own config; assistant turns inherit the + // preceding user's so the header can list mode / effort / plan / fast. + inputConfig: expectedInputConfig, + }; + + if (isEmptyAssistantMessage(message)) return; + if (builder.seenIds.has(message.id)) return; + builder.seenIds.add(message.id); + + const cachedMessageItem = createCachedMessageItem(entry, builder.sessionId, turnIndex, message); + builder.cache.set(message.id, cachedMessageItem); + if (message.role === 'assistant') noteAssistant(builder, message.id, message.finished); + builder.items.push(cachedMessageItem.item); +}; + +const finish = (builder: Builder): BuildChatStreamItemsResult => { + if (!builder.items.length) { + return { + items: [EMPTY_CHAT_STREAM_ITEM], + lastAssistantMessageId: null, + lastCompletedAssistantMessageId: null, + cache: builder.cache, + }; + } + return { + items: builder.items, + lastAssistantMessageId: builder.lastAssistantMessageId, + lastCompletedAssistantMessageId: builder.lastCompletedAssistantMessageId, + cache: builder.cache, + }; +}; + /** * Build the Virtua VList item list from raw session history. * @@ -106,89 +233,52 @@ function createCachedMessageItem( * * `lastAssistantMessageId` is computed over the normalized list so context-window * usage / quick actions attach to the last *rendered* assistant message. + * + * This is the rollback path (no `ConversationView`); `turnIndex` is the + * position in `history`. */ export function buildChatStreamItems( history: readonly SessionHistory[], sessionId: SessionId, previousCache?: BuildChatStreamItemsCache ): BuildChatStreamItemsResult { - const items: ChatStreamItem[] = []; - const seenIds = new Set(); - const cache = new Map(); - let lastAssistantMessageId: string | null = null; - let lastCompletedAssistantMessageId: string | null = null; - /** Config from the latest user turn — attached to the following assistant - * so the model meta row can show the full turn run-config on demand. */ - let lastUserInputConfig: SessionHistoryParsed['inputConfig'] | undefined; - - for (const entry of history) { - if (entry.role === 'user' && entry.inputConfig) { - lastUserInputConfig = entry.inputConfig; - } + const builder = createBuilder(sessionId, previousCache); + for (let turnIndex = 0; turnIndex < history.length; turnIndex += 1) { + const entry = history[turnIndex]; + if (entry) pushHydratedEntry(builder, entry, turnIndex); + } + return finish(builder); +} - const expectedInputConfig = - entry.role === 'user' - ? entry.inputConfig - : entry.role === 'assistant' - ? (entry.inputConfig ?? lastUserInputConfig) - : entry.inputConfig; - - const cached = previousCache?.get(entry.id); - if (canReuseCachedMessageItem(cached, entry, sessionId, expectedInputConfig)) { - if (seenIds.has(entry.id)) continue; - seenIds.add(entry.id); - if (entry.role === 'assistant') { - lastAssistantMessageId = entry.id; - if (entry.finished === true) { - lastCompletedAssistantMessageId = entry.id; - } - } - cache.set(entry.id, cached); - items.push(cached.item); +/** + * The same list read through a `ConversationView`: one item per turn, a + * full message item where the turn is hydrated and a placeholder built from + * the index row everywhere else. Both carry the turn's index and share the + * entry id as their Virtua key, so hydration swaps content under a stable + * key. The normalizations above apply to placeholders from their index row + * (`itemCount` / `planCount`), and the last-assistant ids come from index + * rows too, so they never wait on hydration. + * + * O(turnCount) in cheap work per rebuild; the only `toJSON` cost is what the + * caller already hydrated. + */ +export function buildChatStreamItemsFromView( + view: ConversationView, + sessionId: SessionId, + previousCache?: BuildChatStreamItemsCache +): BuildChatStreamItemsResult { + const builder = createBuilder(sessionId, previousCache); + for (let turnIndex = 0; turnIndex < view.turnCount; turnIndex += 1) { + const entry = view.turn(turnIndex); + if (entry) { + pushHydratedEntry(builder, entry, turnIndex); continue; } - - const message: SessionHistoryParsed = { - id: entry.id, - items: parseHistoryItemsForRender(entry.items), - role: entry.role, - status: entry.status, - read: entry.read ?? false, - timestamp: entry.timestamp, - endedAt: entry.endedAt, - userId: entry.userId, - acpTurnId: entry.acpTurnId, - modelInfo: entry.modelInfo, - fileDiff: entry.fileDiff, - finished: entry.finished, - plan: entry.plan, - // User turns keep their own config; assistant turns inherit the - // preceding user's so the header can list mode / effort / plan / fast. - inputConfig: expectedInputConfig, - }; - - if (isEmptyAssistantMessage(message)) continue; - if (seenIds.has(message.id)) continue; - seenIds.add(message.id); - - const cachedMessageItem = createCachedMessageItem(entry, sessionId, message); - cache.set(message.id, cachedMessageItem); - if (message.role === 'assistant') { - lastAssistantMessageId = message.id; - if (message.finished === true) { - lastCompletedAssistantMessageId = message.id; - } - } - items.push(cachedMessageItem.item); - } - - if (!items.length) { - return { - items: [EMPTY_CHAT_STREAM_ITEM], - lastAssistantMessageId: null, - lastCompletedAssistantMessageId: null, - cache, - }; + const row = view.index(turnIndex); + if (!row?.id || isEmptyAssistantRow(row) || builder.seenIds.has(row.id)) continue; + builder.seenIds.add(row.id); + if (row.role === 'assistant') noteAssistant(builder, row.id, row.finished); + builder.items.push(placeholderItem(row, sessionId, turnIndex)); } - return { items, lastAssistantMessageId, lastCompletedAssistantMessageId, cache }; + return finish(builder); } diff --git a/packages/components/src/components/ai-gui/conversation-outline-rail.tsx b/packages/components/src/components/ai-gui/conversation-outline-rail.tsx index 9435db413..60bd2508e 100644 --- a/packages/components/src/components/ai-gui/conversation-outline-rail.tsx +++ b/packages/components/src/components/ai-gui/conversation-outline-rail.tsx @@ -99,6 +99,11 @@ export interface ConversationOutlineRailProps { enableArrivalIntent?: boolean; /** Storybook/dev instrumentation only. The rail never persists or uploads it. */ onArrivalIntentDebugEvent?: (event: ConversationOutlineArrivalIntentDebugEvent) => void; + /** + * The round whose hover card just opened, by index into `entries`. The + * stream uses it to hydrate a round whose preview is not loaded yet. + */ + onHoverRound?: (index: number) => void; className?: string; } @@ -259,6 +264,7 @@ export function ConversationOutlineRail({ overlayRoot = null, enableArrivalIntent = false, onArrivalIntentDebugEvent, + onHoverRound, className, }: ConversationOutlineRailProps) { const { t } = useTranslation(); @@ -278,6 +284,11 @@ export function ConversationOutlineRail({ const activeIndexRef = useLatestRef(activeIndex); const arrivalIntentDebugRef = useLatestRef(onArrivalIntentDebugEvent); + const onHoverRoundRef = useLatestRef(onHoverRound); + const hoverCardIndex = hoverCard?.index ?? -1; + useEffect(() => { + if (hoverCardIndex !== -1) onHoverRoundRef.current?.(hoverCardIndex); + }, [hoverCardIndex, onHoverRoundRef]); const arrivalIntentDetectorRef = useRef(null); const tickCount = entries.length; diff --git a/packages/components/src/components/ai-gui/index.tsx b/packages/components/src/components/ai-gui/index.tsx index 159fba8e1..8091b7f63 100644 --- a/packages/components/src/components/ai-gui/index.tsx +++ b/packages/components/src/components/ai-gui/index.tsx @@ -5,11 +5,11 @@ import { useEffect, useMemo, useRef, + useState, type MutableRefObject, type ReactNode, } from 'react'; import type { - SessionDoc, SessionFilePayload, SessionHistory, SessionHistoryParsed, @@ -28,10 +28,21 @@ import { type MessageFileDiffEntriesByTurn, type SessionChatStreamHandle, } from './view'; -import { buildChatStreamItems, type BuildChatStreamItemsCache } from './build-chat-stream-items'; +import { + buildChatStreamItems, + buildChatStreamItemsFromView, + type BuildChatStreamItemsCache, +} from './build-chat-stream-items'; import { useStableCallback } from '@/hooks/use-stable-callback'; import { useConversationViewSelector } from '@/hooks/use-conversation-view-selector'; +import { useTurnRange } from '@/hooks/use-turn-range'; import type { ConversationView } from '@/lib/conversation-view'; +import { + computeHydrationRange, + isSameTurnRange, + type TurnRange, +} from '@/lib/conversation-view/hydration-range'; +import { useSessionSearch } from '@/components/sessions/session-search-context'; import { useCloudQuery } from '@lody/platform/react'; import type { SessionNavigationTarget } from '@/lib/session-navigation'; import type { @@ -39,7 +50,10 @@ import type { SessionForkWorktreeAvailability, } from '@/components/sessions/session-fork-destination-menu'; -const emptyHistory = [] as SessionDoc['history']; +const emptyHistory: readonly SessionHistory[] = []; +const EMPTY_TURN_RANGE: TurnRange = { from: 0, to: 0 }; + +const readTurnCount = (view: ConversationView): number => view.turnCount; const findLastUserTurnId = (view: ConversationView): string | null => { for (let index = view.turnCount - 1; index >= 0; index -= 1) { @@ -84,13 +98,15 @@ export { MarkdownRenderer, type MarkdownRendererSize } from './markdown-renderer export interface SessionChatStreamProps { sessionId: SessionId; workspaceId?: WorkspaceId | null; - sessionDoc: SessionDoc; /** - * Windowed history reader for `sessionDoc`'s session. Present on the - * ConversationView path; `null` (or omitted) on the full-Mirror rollback - * path, where `sessionDoc.history` is the only source. + * Windowed history reader for the session. The stream renders through it: + * placeholders from index rows, message rows for the hydrated window + * around the viewport. `null` is the full-Mirror rollback path, which + * renders `fallbackHistory` instead. */ - conversationView?: ConversationView | null; + conversationView: ConversationView | null; + /** The full history array for the rollback path. Not read while a view is present. */ + fallbackHistory?: readonly SessionHistory[]; sessionCreatedAt?: string; dividerLabel?: string; className?: string; @@ -174,8 +190,8 @@ const SessionChatStreamImpl = forwardRef { - const sessionHistory = (sessionDoc.history as SessionHistory[]) ?? emptyHistory; + // The rollback array is only consulted without a view, so the bridge's + // lazy `history` getter is never touched on the view path. + const sessionHistory = conversationView ? emptyHistory : (fallbackHistory ?? emptyHistory); const chatStreamItemsCacheRef = useRef(undefined); if (chatStreamItemsCacheRef.current === undefined) { chatStreamItemsCacheRef.current = getChatStreamItemsCache(sessionId); } + + // ---- Hydration window ---------------------------------------------------- + // The view reports which turns intersect the viewport; the stream keeps + // that window plus two screens on each side hydrated and retained. An + // active in-conversation search hydrates everything instead, because + // search navigation needs every matched turn's rows to exist. That is a + // temporary bridge until the search index reads through the view. + const search = useSessionSearch(); + const searchActive = Boolean(search?.isOpen && search.query); + const turnCount = useConversationViewSelector(conversationView, readTurnCount, 0); + const [visibleTurnRange, setVisibleTurnRange] = useState(null); + const hydrationRange = useMemo(() => { + if (!conversationView) return EMPTY_TURN_RANGE; + if (searchActive) return { from: 0, to: turnCount }; + return computeHydrationRange(visibleTurnRange, turnCount); + }, [conversationView, searchActive, turnCount, visibleTurnRange]); + const viewRevision = useTurnRange(conversationView, hydrationRange.from, hydrationRange.to); + // Hover hydration (outline previews) does not bump the view's version. + const [hoverHydrationRevision, setHoverHydrationRevision] = useState(0); + const handleVisibleTurnRangeChange = useCallback((from: number, to: number) => { + setVisibleTurnRange((previous) => + isSameTurnRange(previous, { from, to }) ? previous : { from, to } + ); + }, []); + const handleOutlineHoverTurn = useCallback( + (turnIndex: number) => { + if (!conversationView || conversationView.isHydrated(turnIndex)) return; + void conversationView.ensureRange(turnIndex, turnIndex + 1).then(() => { + setHoverHydrationRevision((revision) => revision + 1); + }); + }, + [conversationView] + ); + const { items, lastAssistantMessageId, lastCompletedAssistantMessageId, cache } = useMemo( - () => buildChatStreamItems(sessionHistory, sessionId, chatStreamItemsCacheRef.current), - [sessionHistory, sessionId] + () => + conversationView + ? buildChatStreamItemsFromView( + conversationView, + sessionId, + chatStreamItemsCacheRef.current + ) + : buildChatStreamItems(sessionHistory, sessionId, chatStreamItemsCacheRef.current), + // The revisions are the view's change signals; the builder reads the + // view directly. + // eslint-disable-next-line react-hooks/exhaustive-deps + [conversationView, hoverHydrationRevision, sessionHistory, sessionId, viewRevision] ); chatStreamItemsCacheRef.current = cache; useEffect(() => { @@ -318,6 +380,8 @@ const SessionChatStreamImpl = forwardRef ); } diff --git a/packages/components/src/components/ai-gui/turn-placeholder-estimate.ts b/packages/components/src/components/ai-gui/turn-placeholder-estimate.ts new file mode 100644 index 000000000..85ed1f1ce --- /dev/null +++ b/packages/components/src/components/ai-gui/turn-placeholder-estimate.ts @@ -0,0 +1,45 @@ +import type { TurnIndexRow } from '@/lib/conversation-view'; + +/** + * Height a not-yet-hydrated turn is given so Virtua's scroll geometry stays + * close to what the hydrated rows will measure. A finished assistant turn + * folds its work into one "Worked for" row, so activity contributes one row + * regardless of count; prose adds lines. Without a summary the item count is + * the only signal, and without that a role-based constant. + */ +export const PLACEHOLDER_BASE_HEIGHT_PX = { user: 84, assistant: 132, system: 64 } as const; +const PROSE_CHARS_PER_LINE = 90; +const PROSE_LINE_HEIGHT_PX = 22; +const ACTIVITY_ROW_HEIGHT_PX = 40; +const ITEM_HEIGHT_PX = 28; +const MIN_HEIGHT_PX = 56; +const MAX_HEIGHT_PX = 1600; + +const clamp = (value: number): number => + Math.min(Math.max(Math.round(value), MIN_HEIGHT_PX), MAX_HEIGHT_PX); + +export function estimateTurnHeightPx( + row: Pick +): number { + const base = + row.role === 'user' + ? PLACEHOLDER_BASE_HEIGHT_PX.user + : row.role === 'assistant' + ? PLACEHOLDER_BASE_HEIGHT_PX.assistant + : PLACEHOLDER_BASE_HEIGHT_PX.system; + const summary = row.summary; + if (summary) { + const proseLines = Math.ceil(Math.max(summary.textChars, 0) / PROSE_CHARS_PER_LINE); + const activity = summary.activity; + const activityCount = + (activity?.commandCount ?? 0) + + (activity?.editFileCount ?? 0) + + (activity?.readFileCount ?? 0) + + (activity?.searchCount ?? 0); + return clamp( + base + proseLines * PROSE_LINE_HEIGHT_PX + (activityCount > 0 ? ACTIVITY_ROW_HEIGHT_PX : 0) + ); + } + if (row.itemCount !== undefined) return clamp(base + row.itemCount * ITEM_HEIGHT_PX); + return clamp(base); +} diff --git a/packages/components/src/components/ai-gui/turn-placeholder-row.tsx b/packages/components/src/components/ai-gui/turn-placeholder-row.tsx new file mode 100644 index 000000000..05868b25b --- /dev/null +++ b/packages/components/src/components/ai-gui/turn-placeholder-row.tsx @@ -0,0 +1,73 @@ +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TurnIndexRow } from '@/lib/conversation-view'; +import { ConversationColumn } from '@/components/shared/conversation-column'; +import { estimateTurnHeightPx } from './turn-placeholder-estimate'; + +/** + * Stand-in for a turn the `ConversationView` has not hydrated: the role, the + * sealed summary's opening words and activity counts when the turn carries + * them, and otherwise a quiet skeleton. Its height is the estimate Virtua + * measures, so the scrollbar and far jumps land near where the hydrated rows + * will. It carries no interaction: the stream replaces it under the same key + * as soon as the turn is in the hydration window. + */ +export const TurnPlaceholderRow = memo(function TurnPlaceholderRow({ row }: { row: TurnIndexRow }) { + const { t } = useTranslation(); + const summary = row.summary; + const headText = typeof summary?.headText === 'string' ? summary.headText.trim() : ''; + const commandCount = summary?.activity?.commandCount ?? 0; + const editFileCount = summary?.activity?.editFileCount ?? 0; + const roleLabel = + row.role === 'user' + ? t('sessions.turnPlaceholder.user', 'You') + : row.role === 'assistant' + ? t('sessions.turnPlaceholder.assistant', 'Agent') + : t('sessions.turnPlaceholder.system', 'System'); + + return ( + +
+
+ {roleLabel} + {row.timestamp ? ( + + ) : null} +
+ {headText ? ( +

{headText}

+ ) : ( + ), }; + +/** + * 3,000 synthetic turns through a real Loro doc and `ConversationView`: the + * production read path minus the workspace runtime. Every third assistant + * turn is sealed with a `summary`, so its placeholder and outline entry come + * from index rows; the rest hydrate when scrolled into the window, on an + * outline hover, or when a search is active. Exercises far scrolling over + * placeholders, outline jumps into never-measured territory, and expanding a + * folded turn after it hydrates. + */ +const EXTREME_VIEW_TURNS = 3_000; +const extremeViewSessionId = 'extreme-view' as SessionId; + +const buildExtremeViewDoc = (): LoroDoc => { + const doc = new LoroDoc(); + for (let index = 0; index < EXTREME_VIEW_TURNS; index += 1) { + const round = Math.floor(index / 2); + const historyEntry: SessionHistoryInput = + index % 2 === 0 + ? { + id: `xv-user-${round}`, + role: 'user', + timestamp: new Date(Date.UTC(2026, 0, 1, 0, round % 60)).toISOString(), + status: 'seen', + read: true, + finished: true, + fileDiff: [], + items: [{ type: 'text', text: extremeUserText(round) }] as never, + inputConfig: { + prompt: `Round ${round + 1}`, + cliType: 'builtin', + agentType: 'claude', + } as never, + } + : { + id: `xv-assistant-${round}`, + role: 'assistant', + timestamp: new Date(Date.UTC(2026, 0, 1, 0, round % 60, 30)).toISOString(), + finished: true, + endedAt: Date.UTC(2026, 0, 1, 0, round % 60, 45), + fileDiff: [], + items: [ + { type: 'thought', text: `Thinking about round ${round + 1}.` }, + { + type: 'tool_call', + toolCallId: `xv-tool-${round}`, + title: 'Run tests', + kind: 'execute', + status: 'completed', + content: [{ type: 'terminal_command', command: 'pnpm test', cwd: '/repo' }], + }, + { + type: 'text', + text: `Answer for round ${round + 1}.\n\n${paragraphs(1 + (round % 4), round)}`, + }, + ] as never, + ...(round % 3 === 0 + ? { + summary: { + itemCount: 3, + textChars: 200 + (round % 4) * 400, + thoughtChars: 30, + headText: `Answer for round ${round + 1}.`, + activity: { + commandCount: 1, + editFileCount: round % 2, + readFileCount: 0, + searchCount: 0, + failedCount: 0, + }, + editedPaths: [], + }, + } + : {}), + }; + appendHistoryEntry(doc, historyEntry); + } + return doc; +}; + +let extremeViewDoc: LoroDoc | null = null; +const getExtremeView = (): ConversationView => { + extremeViewDoc ??= buildExtremeViewDoc(); + return createConversationViewFromDoc(extremeViewDoc, { sessionId: extremeViewSessionId }); +}; + +const readTurnCount = (view: ConversationView): number => view.turnCount; + +function ExtremeConversationViewFrame() { + const view = useMemo(() => getExtremeView(), []); + const turnCount = useConversationViewSelector(view, readTurnCount, 0); + const [visibleRange, setVisibleRange] = useState(null); + const hydrationRange = useMemo( + () => computeHydrationRange(visibleRange, turnCount), + [turnCount, visibleRange] + ); + const revision = useTurnRange(view, hydrationRange.from, hydrationRange.to); + const [hoverRevision, setHoverRevision] = useState(0); + const { items, lastAssistantMessageId, lastCompletedAssistantMessageId } = useMemo( + () => buildChatStreamItemsFromView(view, extremeViewSessionId), + // Revisions are the view's change signals. + // eslint-disable-next-line react-hooks/exhaustive-deps + [view, revision, hoverRevision] + ); + const handleVisibleTurnRangeChange = useCallback((from: number, to: number) => { + setVisibleRange((previous) => + isSameTurnRange(previous, { from, to }) ? previous : { from, to } + ); + }, []); + const handleOutlineHoverTurn = useCallback( + (turnIndex: number) => { + if (view.isHydrated(turnIndex)) return; + void view.ensureRange(turnIndex, turnIndex + 1).then(() => setHoverRevision((r) => r + 1)); + }, + [view] + ); + const hydratedCount = items.filter((item) => item.type === 'message').length; + return ( +
+
+ turns {turnCount} · hydrated {hydratedCount} · window {hydrationRange.from}– + {hydrationRange.to} +
+
+ +
+
+ ); +} + +export const ExtremeConversationView: Story = { + args: { entries: [], activeIndex: -1, onJumpToRound: () => {} }, + render: () => , +}; diff --git a/packages/components/tests/ai-gui-reads-history-through-view.test.ts b/packages/components/tests/ai-gui-reads-history-through-view.test.ts new file mode 100644 index 000000000..9885d80a2 --- /dev/null +++ b/packages/components/tests/ai-gui-reads-history-through-view.test.ts @@ -0,0 +1,41 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * The conversation renderer reads history only through `ConversationView` + * (or the explicit `fallbackHistory` array its owner hands it on the + * rollback path). A `sessionDoc.history` / `doc.history` read anywhere under + * `components/ai-gui` would go through the store bridge, whose first access + * materializes the whole transcript — exactly the O(n) open this renderer + * exists to avoid. If this fails on a file you just added, take the value + * from the view (index rows, `turn(i)`, the built items) instead. + */ + +const AI_GUI = join(__dirname, '..', 'src', 'components', 'ai-gui'); +const FORBIDDEN = [/\bsessionDoc\.history\b/, /\bdoc\.history\b/, /\bsessionDoc\?\.history\b/]; + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + walk(full, out); + } else if (/\.(ts|tsx)$/.test(entry)) { + out.push(full); + } + } + return out; +} + +describe('components/ai-gui reads history only through ConversationView', () => { + it('never spells sessionDoc.history or doc.history', () => { + const offenders: string[] = []; + for (const file of walk(AI_GUI)) { + const source = readFileSync(file, 'utf8'); + for (const pattern of FORBIDDEN) { + if (pattern.test(source)) offenders.push(`${relative(AI_GUI, file)}: ${pattern.source}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/components/tests/build-chat-stream-items.test.ts b/packages/components/tests/build-chat-stream-items.test.ts index ac50493cd..adb911e0f 100644 --- a/packages/components/tests/build-chat-stream-items.test.ts +++ b/packages/components/tests/build-chat-stream-items.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import type { SessionHistory, SessionId } from '@lody/shared'; -import { buildChatStreamItems } from '../src/components/ai-gui/build-chat-stream-items'; +import { LoroDoc } from 'loro-crdt'; +import type { SessionHistory, SessionHistoryInput, SessionId } from '@lody/shared'; +import { + buildChatStreamItems, + buildChatStreamItemsFromView, +} from '../src/components/ai-gui/build-chat-stream-items'; +import { appendHistoryEntry, createConversationViewFromDoc } from '../src/lib/conversation-view'; const sessionId = 'session-test' as SessionId; @@ -196,3 +201,98 @@ describe('buildChatStreamItems', () => { expect(second.lastAssistantMessageId).toBe('assistant-2'); }); }); + +describe('buildChatStreamItemsFromView', () => { + const viewSessionId = 'session-view' as SessionId; + const turn = (index: number): SessionHistoryInput => + index % 2 === 0 + ? { + id: `u${index}`, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + status: 'seen', + read: true, + finished: true, + fileDiff: [], + items: [{ type: 'text', text: `prompt ${index}` }] as never, + inputConfig: { + prompt: `prompt ${index}`, + cliType: 'builtin', + agentType: 'claude', + } as never, + } + : { + id: `a${index}`, + role: 'assistant', + timestamp: '2026-01-01T00:01:00.000Z', + finished: index !== 9, + fileDiff: [], + items: index === 5 ? [] : ([{ type: 'text', text: `answer ${index}` }] as never), + }; + const docWithTurns = (count: number): LoroDoc => { + const doc = new LoroDoc(); + for (let index = 0; index < count; index += 1) appendHistoryEntry(doc, turn(index)); + return doc; + }; + + it('renders placeholders outside the hydrated window under the entry id, and messages inside', async () => { + const doc = docWithTurns(12); + const view = createConversationViewFromDoc(doc, { sessionId: viewSessionId, tailKeep: 2 }); + const first = buildChatStreamItemsFromView(view, viewSessionId); + + // a5 is an empty assistant turn: dropped from its index row, as the array path drops it. + expect( + first.items.map((item) => + item.type === 'placeholder' + ? `p:${item.row.id}` + : item.type === 'message' + ? `m:${item.message.id}` + : 'empty' + ) + ).toEqual([ + 'p:u0', + 'p:a1', + 'p:u2', + 'p:a3', + 'p:u4', + 'p:u6', + 'p:a7', + 'p:u8', + 'p:a9', + 'm:u10', + 'm:a11', + ]); + expect(first.items.map((item) => (item.type === 'empty' ? -1 : item.turnIndex))).toEqual([ + 0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, + ]); + // Last-assistant ids come from index rows, so they do not wait on hydration. + expect(first.lastAssistantMessageId).toBe('a11'); + expect(first.lastCompletedAssistantMessageId).toBe('a11'); + + await view.ensureRange(2, 4); + const second = buildChatStreamItemsFromView(view, viewSessionId, first.cache); + expect(second.items[2]).toMatchObject({ type: 'message', message: { id: 'u2' }, turnIndex: 2 }); + expect(second.items[3]).toMatchObject({ type: 'message', message: { id: 'a3' }, turnIndex: 3 }); + // Untouched placeholders and hydrated tail messages keep their identity. + expect(second.items[0]).toBe(first.items[0]); + expect(second.items[9]).toBe(first.items[9]); + expect(second.items[10]).toBe(first.items[10]); + view.dispose(); + }); + + it('tracks the open tail turn and index-only completion state', () => { + const doc = docWithTurns(10); + const view = createConversationViewFromDoc(doc, { sessionId: viewSessionId, tailKeep: 0 }); + const result = buildChatStreamItemsFromView(view, viewSessionId); + expect(result.items.every((item) => item.type === 'placeholder')).toBe(true); + expect(result.lastAssistantMessageId).toBe('a9'); + expect(result.lastCompletedAssistantMessageId).toBe('a7'); + view.dispose(); + }); + + it('gives the array path the same turn indexes', () => { + const history = [0, 1, 2].map((index) => turn(index)) as unknown as SessionHistory[]; + const { items } = buildChatStreamItems(history, viewSessionId); + expect(items.map((item) => (item.type === 'message' ? item.turnIndex : -1))).toEqual([0, 1, 2]); + }); +}); diff --git a/packages/components/tests/chat-virtual-rows-identity.test.ts b/packages/components/tests/chat-virtual-rows-identity.test.ts index 66c4fc3e0..1c150b281 100644 --- a/packages/components/tests/chat-virtual-rows-identity.test.ts +++ b/packages/components/tests/chat-virtual-rows-identity.test.ts @@ -142,3 +142,30 @@ describe('buildChatVirtualRows per-turn row identity', () => { }); }); }); + +describe('buildChatVirtualRows placeholders', () => { + it('emits one row per placeholder under the entry id and carries turn indexes', () => { + const row = { id: 'p-1', role: 'assistant', timestamp: '2026-01-01T00:00:00.000Z' } as const; + const items: ChatStreamItem[] = [ + { ...wrap(makeMessage('u-1', 'user', [text('hello')])), turnIndex: 0 }, + { type: 'placeholder', sessionId, turnIndex: 1, row }, + { ...wrap(makeMessage('a-2', 'assistant', [text('answer')], true)), turnIndex: 2 }, + ]; + const rows = build(items); + expect(rows.slice(0, 2).map((r) => [r.type, r.key, r.turnIndex])).toEqual([ + ['standard', 'u-1', 0], + ['placeholder', 'p-1', 1], + ]); + const assistantRows = rows.slice(2); + expect(assistantRows.length).toBeGreaterThan(0); + expect(assistantRows.every((r) => r.type === 'assistant' && r.turnIndex === 2)).toBe(true); + // Rebuilding with the same items hands back the same placeholder row item. + const again = build(items); + expect(again[1]?.item).toBe(rows[1]?.item); + }); + + it('defaults a message turn index to its position on the rollback path', () => { + const rows = build([wrap(makeMessage('u-1', 'user', [text('hello')]))]); + expect(rows[0]?.turnIndex).toBe(0); + }); +}); diff --git a/packages/components/tests/conversation-outline.test.ts b/packages/components/tests/conversation-outline.test.ts index 26eb0bf12..7a067b0d3 100644 --- a/packages/components/tests/conversation-outline.test.ts +++ b/packages/components/tests/conversation-outline.test.ts @@ -293,3 +293,56 @@ describe('summary stability while streaming', () => { expect(grown[0]?.preview).toBe('short but then longer'); }); }); + +describe('buildConversationOutline with placeholders', () => { + const placeholder = ( + role: 'user' | 'assistant', + summary?: { headText: string; textChars: number; thoughtChars?: number } + ): ConversationOutlineSource => { + nextId += 1; + return { + type: 'placeholder', + row: { + id: `row-${nextId}`, + role, + timestamp: '2026-08-19T00:00:00.000Z', + ...(summary + ? { + summary: { + itemCount: 1, + textChars: summary.textChars, + thoughtChars: summary.thoughtChars ?? 0, + headText: summary.headText, + activity: { + commandCount: 0, + editFileCount: 0, + readFileCount: 0, + searchCount: 0, + failedCount: 0, + }, + editedPaths: [], + }, + } + : {}), + }, + }; + }; + + it('titles and previews rounds from sealed summaries and weighs them by textChars', () => { + const outline = buildConversationOutline([ + placeholder('user', { headText: '# Fix the **flaky** test', textChars: 24 }), + placeholder('assistant', { headText: 'Looking at the test now.', textChars: 4_000 }), + placeholder('user'), + message('assistant', [text('Hydrated reply')]), + ]); + expect(outline).toHaveLength(2); + expect(outline[0]).toMatchObject({ + title: 'Fix the flaky test', + preview: 'Looking at the test now.', + weight: 3, + startsWithAgent: false, + }); + // A placeholder without a summary keeps an empty title until hydrated. + expect(outline[1]).toMatchObject({ title: '', preview: 'Hydrated reply' }); + }); +}); diff --git a/packages/components/tests/hydration-range.test.ts b/packages/components/tests/hydration-range.test.ts new file mode 100644 index 000000000..7883a40ba --- /dev/null +++ b/packages/components/tests/hydration-range.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + computeHydrationRange, + HYDRATION_RANGE_QUANTUM, + isSameTurnRange, + MIN_PREFETCH_TURNS, + TAIL_HYDRATION_WINDOW, +} from '../src/lib/conversation-view/hydration-range'; +import { resolveVisibleTurnRange } from '../src/components/ai-gui/view'; + +describe('computeHydrationRange', () => { + it('is the tail window before any scroll position is known', () => { + expect(computeHydrationRange(null, 2_400)).toEqual({ + from: 2_400 - TAIL_HYDRATION_WINDOW, + to: 2_400, + }); + expect(computeHydrationRange(null, 10)).toEqual({ from: 0, to: 10 }); + expect(computeHydrationRange(null, 0)).toEqual({ from: 0, to: 0 }); + }); + + it('prefetches two visible spans on each side, snapped to the quantum', () => { + const range = computeHydrationRange({ from: 1_000, to: 1_012 }, 2_400); + // span 12 → prefetch 24: [976, 1036) snaps to multiples of 8. + expect(range).toEqual({ from: 976, to: 1_040 }); + expect(range.from % HYDRATION_RANGE_QUANTUM).toBe(0); + expect(range.to % HYDRATION_RANGE_QUANTUM).toBe(0); + }); + + it('never prefetches less than the floor and clamps to the conversation', () => { + expect(computeHydrationRange({ from: 2, to: 3 }, 2_400)).toEqual({ + from: 0, + to: Math.ceil((3 + MIN_PREFETCH_TURNS) / HYDRATION_RANGE_QUANTUM) * HYDRATION_RANGE_QUANTUM, + }); + expect(computeHydrationRange({ from: 2_390, to: 2_400 }, 2_400)).toEqual({ + from: 2_368, + to: 2_400, + }); + }); + + it('keeps the same range object identity through isSameTurnRange', () => { + const range = { from: 1, to: 2 }; + expect(isSameTurnRange(range, { from: 1, to: 2 })).toBe(true); + expect(isSameTurnRange(range, { from: 1, to: 3 })).toBe(false); + expect(isSameTurnRange(null, range)).toBe(false); + expect(isSameTurnRange(null, null)).toBe(true); + }); +}); + +describe('resolveVisibleTurnRange', () => { + const rows = [ + { turnIndex: 0 }, + { turnIndex: 1 }, + { turnIndex: 1 }, + { turnIndex: 1 }, + { turnIndex: 2 }, + { turnIndex: 3 }, + ]; + + it('maps the rows under the viewport edges to a half-open turn window', () => { + expect(resolveVisibleTurnRange(rows, 1, 4)).toEqual({ from: 1, to: 3 }); + expect(resolveVisibleTurnRange(rows, 2, 2)).toEqual({ from: 1, to: 2 }); + }); + + it('clamps the leading-content and agent-activity rows into the list', () => { + expect(resolveVisibleTurnRange(rows, -1, 99)).toEqual({ from: 0, to: 4 }); + expect(resolveVisibleTurnRange([], 0, 0)).toBeNull(); + }); + + it('skips the empty-conversation row', () => { + expect(resolveVisibleTurnRange([{ turnIndex: -1 }], 0, 0)).toBeNull(); + expect(resolveVisibleTurnRange([{ turnIndex: -1 }, { turnIndex: 5 }], 0, 1)).toEqual({ + from: 5, + to: 6, + }); + }); +}); diff --git a/packages/components/tests/turn-placeholder-estimate.test.ts b/packages/components/tests/turn-placeholder-estimate.test.ts new file mode 100644 index 000000000..a95f8fdab --- /dev/null +++ b/packages/components/tests/turn-placeholder-estimate.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { + estimateTurnHeightPx, + PLACEHOLDER_BASE_HEIGHT_PX, +} from '../src/components/ai-gui/turn-placeholder-estimate'; + +const summary = (textChars: number, commandCount = 0) => ({ + itemCount: 3, + textChars, + thoughtChars: 0, + headText: 'head', + activity: { commandCount, editFileCount: 0, readFileCount: 0, searchCount: 0, failedCount: 0 }, + editedPaths: [], +}); + +describe('estimateTurnHeightPx', () => { + it('uses a role constant with no other signal', () => { + expect(estimateTurnHeightPx({ role: 'user' })).toBe(PLACEHOLDER_BASE_HEIGHT_PX.user); + expect(estimateTurnHeightPx({ role: 'assistant' })).toBe(PLACEHOLDER_BASE_HEIGHT_PX.assistant); + expect(estimateTurnHeightPx({ role: 'system' })).toBe(PLACEHOLDER_BASE_HEIGHT_PX.system); + }); + + it('grows with summary prose and counts folded activity once', () => { + const short = estimateTurnHeightPx({ role: 'assistant', summary: summary(90) }); + const long = estimateTurnHeightPx({ role: 'assistant', summary: summary(9_000) }); + const busy = estimateTurnHeightPx({ role: 'assistant', summary: summary(90, 12) }); + const busier = estimateTurnHeightPx({ role: 'assistant', summary: summary(90, 40) }); + expect(long).toBeGreaterThan(short); + expect(busy).toBeGreaterThan(short); + expect(busier).toBe(busy); + expect(long).toBeLessThanOrEqual(1_600); + }); + + it('falls back to the item count without a summary', () => { + expect(estimateTurnHeightPx({ role: 'assistant', itemCount: 10 })).toBeGreaterThan( + estimateTurnHeightPx({ role: 'assistant', itemCount: 1 }) + ); + }); +});