feat(components): render the conversation stream from ConversationView in O(window) (phase 1b-B) - #364
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 45dec25e97
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| for (let turnIndex = 0; turnIndex < view.turnCount; turnIndex += 1) { | ||
| const entry = view.turn(turnIndex); |
There was a problem hiding this comment.
Bound stream rebuilding to the active window
Whenever a long conversation is streaming, each ConversationView version re-runs this loop across every turn and emits placeholders for the entire unhydrated history; SessionChatStreamView subsequently maps that complete list into React children. The per-update work therefore remains O(total turns), not O(window), so a 3,000-turn session still creates and reconciles thousands of entries on each streamed update and can retain the UI stalls this change is intended to eliminate. Build only the retained/visible window and represent the off-window geometry without rebuilding every turn.
Useful? React with 👍 / 👎.
45dec25 to
8fd9e87
Compare
…w 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
8fd9e87 to
ae4070f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ae4070f6e9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const item = entry ? items[entry.messageIndex] : undefined; | ||
| const turnIndex = | ||
| item?.type === 'placeholder' | ||
| ? item.turnIndex |
There was a problem hiding this comment.
Hydrate the reply when opening an outline preview
For an off-window round whose stored turns lack summaries, entry.messageIndex points only to the round-opening user turn, so this callback hydrates that user turn but leaves the following assistant turn as a summary-less placeholder. buildConversationOutline therefore keeps the preview empty and the hover card incorrectly displays “No reply yet” even though a reply exists; hydrate the round through its first assistant prose rather than only its anchor turn.
AGENTS.md reference: packages/components/src/components/ai-gui/AGENTS.md:L34-L39
Useful? React with 👍 / 👎.
Summary
Phase 1b-B: the conversation renderer reads history only through
ConversationViewand does O(window) work.SessionChatStreamtakesconversationView(plus the owner's explicitfallbackHistoryarray on the rollback path) and never readssessionDoc.history.tests/ai-gui-reads-history-through-view.test.tsfails if that spelling returns anywhere undercomponents/ai-gui.buildChatStreamItemsFromViewemits one item per turn: a message item where the turn is hydrated, aplaceholderitem from the index row elsewhere. Both carryturnIndex; a placeholder uses the entry id as its Virtua key so hydration swaps content under a stable key. Empty assistant turns are dropped fromitemCount/planCount, ids de-duplicated, andlastAssistantMessageId/lastCompletedAssistantMessageIdcome from index rows.TurnPlaceholderRowrenders role, time,summary.headTextand activity counts when present;minHeightisestimateTurnHeightPx(summary prose/activity → item count → role constant), which Virtua measures as the row's real size.shift={false}unchanged.[from, to)turns from Virtua's offset math (findItemIndexat the viewport edges; 0.49.1 has nofindStartIndex) after the initial scroll restore.computeHydrationRangeadds two spans on each side, snapped to an 8-turn quantum;useTurnRangeretains + hydrates it and re-renders once per frame of view changes. An active in-conversation search hydrates every turn (documented as temporary until the search index reads through the view).summary.headTextas title/preview,textChars + thoughtCharsas weight); a round without a summary keeps the untitled fallback until the rail's newonHoverRoundhydrates it lazily.SessionChatStreamHandle.scrollToIndexnow takes a turn index; rows resolve throughrow.turnIndex. Search results already carry the history index, which is the turn index.leadingContentas a real row,scrollRowToTopas the only index→scroll conversion, the outline correction loop.ai-gui/AGENTS.mdgains a "History Source" section.ExtremeConversationView— 3,000 synthetic turns through a Loro doc and a realConversationView(scroll over placeholders, outline jumps into unmeasured territory, expansion after hydration).Tests
build-chat-stream-items(view path, placeholders, identity),chat-virtual-rows-identity(placeholder rows,turnIndex),conversation-outline(placeholder digests),hydration-range(range math +resolveVisibleTurnRange),turn-placeholder-estimate,ai-gui-reads-history-through-view.🤖 Generated with Claude Code
Benchmarks (from the stacked PR C,
pnpm --filter @lody/history-import bench:open)before = full-schema Mirror open (the path this stack replaces), after =
view.open(import +ConversationView+ tail hydrate → renderable rows). M-series laptop, means unless noted.unreachable)Acceptance:
open≤ 50 ms at ×10 — met on the synthetic ×10 fixture (46.2 ms); on the item-heavy real fixture (~100 items per turn) it is 90 ms at ×1 and 199 ms at ×10 (117 ms of which is decoding the 48.5 MiB snapshot), where the full-Mirror path fails outright, dominated by the 20-turn tail hydrate.streamp99 ≤ 4 ms — met everywhere.Rollback:
VITE_LODY_CONVERSATION_VIEW=0at build time orlocalStorage['lody:conversationView'] = '0'at runtime restores the full-Mirror path.