Skip to content

feat(components): render the conversation stream from ConversationView in O(window) (phase 1b-B) - #364

Open
zxch3n wants to merge 1 commit into
feat/conversation-view-store-wiringfrom
feat/conversation-view-renderer
Open

feat(components): render the conversation stream from ConversationView in O(window) (phase 1b-B)#364
zxch3n wants to merge 1 commit into
feat/conversation-view-store-wiringfrom
feat/conversation-view-renderer

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 1b-B: the conversation renderer reads history only through ConversationView and does O(window) work.

  • SessionChatStream takes conversationView (plus the owner's explicit fallbackHistory array on the rollback path) and never reads sessionDoc.history. tests/ai-gui-reads-history-through-view.test.ts fails if that spelling returns anywhere under components/ai-gui.
  • buildChatStreamItemsFromView emits one item per turn: a message item where the turn is hydrated, a placeholder item from the index row elsewhere. Both carry turnIndex; 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 de-duplicated, and lastAssistantMessageId / lastCompletedAssistantMessageId come from index rows.
  • TurnPlaceholderRow renders role, time, summary.headText and activity counts when present; minHeight is estimateTurnHeightPx (summary prose/activity → item count → role constant), which Virtua measures as the row's real size. shift={false} unchanged.
  • Visible window: the view reports [from, to) turns from Virtua's offset math (findItemIndex at the viewport edges; 0.49.1 has no findStartIndex) after the initial scroll restore. computeHydrationRange adds two spans on each side, snapped to an 8-turn quantum; useTurnRange retains + 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).
  • Outline: entries come from index rows for placeholders (summary.headText as title/preview, textChars + thoughtChars as weight); a round without a summary keeps the untitled fallback until the rail's new onHoverRound hydrates it lazily.
  • SessionChatStreamHandle.scrollToIndex now takes a turn index; rows resolve through row.turnIndex. Search results already carry the history index, which is the turn index.
  • Invariants preserved: stable Virtua keys, leadingContent as a real row, scrollRowToTop as the only index→scroll conversion, the outline correction loop. ai-gui/AGENTS.md gains a "History Source" section.
  • Storybook: ExtremeConversationView — 3,000 synthetic turns through a Loro doc and a real ConversationView (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.

Fixture Turns / items before after scroll 30 turns p99 stream p99
synthetic ×1 240 / 1,080 49.9 ms 6.3 ms 4.3 ms 0.22 ms
synthetic ×10 2,400 / 10,800 8.2–40 s 46.2 ms 17.2 ms 0.18 ms
real, desensitized ×1 57 / 5,672 2,210 ms 90.0 ms 148 ms 0.08 ms
real, desensitized ×10 570 / 56,720 fails (loro-mirror unreachable) 199 ms (117 ms snapshot decode) 406 ms 0.08 ms

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. stream p99 ≤ 4 ms — met everywhere.

Rollback: VITE_LODY_CONVERSATION_VIEW=0 at build time or localStorage['lody:conversationView'] = '0' at runtime restores the full-Mirror path.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +271 to +272
for (let turnIndex = 0; turnIndex < view.turnCount; turnIndex += 1) {
const entry = view.turn(turnIndex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

…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
@zxch3n
zxch3n force-pushed the feat/conversation-view-renderer branch from 8fd9e87 to ae4070f Compare September 4, 2026 00:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +1707 to +1710
const item = entry ? items[entry.messageIndex] : undefined;
const turnIndex =
item?.type === 'placeholder'
? item.turnIndex

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant