feat: open any-length conversations in O(window) via ConversationView - #376
feat: open any-length conversations in O(window) via ConversationView#376zxch3n wants to merge 12 commits into
Conversation
…e through HistoryWriter The session store no longer materializes `history`: the control-plane Mirror uses `sessionControlPlaneSchema` (`history: schema.Ignore()`) over a doc facade that drops history events and skips root enumeration, turns are read through a windowed `ConversationView` (index rows from shallow reads, tail hydrated eagerly within an item budget, LRU with pinned ranges, idle summaries, incremental copy-on-write patches from doc events), and writes go through `HistoryWriter`, whose container shape is op-for-op what `Mirror.setState` produced. Hooks that scanned `getState().history` now use the writer's `read`/`replace` or index queries. `LODY_CONVERSATION_VIEW=0` or the Developer-mode switch restores the full Mirror for one release. Model: claude-fable-5-1
…lder rows `SessionChatStream` takes `view`; `buildChatStreamItems` yields a message or an index-row placeholder per turn, rows/outline/`scrollToIndex` use absolute turn indexes, `TurnPlaceholderRow` sizes non-hydrated turns from the row summary, and the viewport reports its turn range to drive hydration with two screens of prefetch. Every full-history reader in the session surfaces moves to the hydrated tail, index rows, or the background per-turn fact table (`useSessionTurnFacts`); search hydrates while open and export on demand. A guard test fails on any `sessionDoc.history` read under `src/components`, and a 3,000-turn doc-backed story exercises scroll, outline jumps, and expansion. Model: claude-fable-5-1
…sationView `bench:open` runs on the synthetic replay by default (a desensitized real fixture stays local) and adds `open`, `open+idle`, `scroll`, `stream`, and `stream(Mirror)` next to the full-Mirror baseline. Model: claude-fable-5-1
…Actions tests Dispatch, steer, and pending_apply promotion now read and replace turns through `store.historyWriter`, so the runtime stubs expose one over their history array. Model: claude-fable-5-1
…t exhaust wasm memory A `LoroDoc`'s memory lives in the wasm heap and is reclaimed only when V8 finalizes its JS wrapper, which it does lazily. The open/import tasks build one doc per iteration, so at x10 (2,400 turns, ~108k containers) the heap filled mid-run and the next string crossing trapped with `RuntimeError: unreachable`. Model: claude-opus-5[1m]
…open stays under 50 ms The eager pass resolved `itemCount` / `planCount` per turn, which costs a `getContainerById` plus a `length` crossing each: measured on the x10 synthetic fixture (2,400 turns) that is ~17 ms of a 77 ms first paint, for numbers nothing on screen needs yet. Counts now arrive with the turn's summary from the idle pass, exactly when a turn is hydrated, or on demand for the tail turns whose hydration budget needs them. An unresolved count reads as unknown rather than zero, so `isEmptyAssistantIndexRow` and the permission scan treat it as "not empty": a real turn is never dropped from the stream, and only an interrupted turn shows a placeholder until its counts land. x10 open: 77.3 ms -> 48.1 ms mean (p99 104 -> 57), stream p99 0.04 ms. Model: claude-opus-5[1m]
…ve view The 2,000-turn fixture appended every turn through `HistoryWriter` while a `ConversationView` was subscribed to the same doc, so each append also ran a full event pass — tail hydrate, LRU eviction, summary refresh. That is ~900 ms of test-only work locally and pushed the test past vitest's 5 s budget on a two-worker CI runner. `append` never consults the view (it inserts at the tail), so the build uses an unattached stub instead: 1232 ms -> 463 ms locally. The fixture stays rich on purpose — a full Mirror over it costs ~272 ms against the 30 ms bound, while a one-text-item fixture would materialize in ~45 ms and leave the assertion no usable margin. Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0c2c41c384
ℹ️ 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".
| if (change.kind === 'index') { | ||
| if (view.turnCount < lastTurnCount) changed = pruneRemoved() || changed; | ||
| lastTurnCount = view.turnCount; | ||
| // Appended turns land in the hydrated tail; derive whatever is there. | ||
| changed = deriveHydratedRange(Math.max(0, view.turnCount - 64), view.turnCount) || changed; |
There was a problem hiding this comment.
Invalidate facts when a non-hydrated turn changes
After the background pass has derived an older turn, a later update to that non-hydrated turn emits an index change, but this branch only re-derives the last 64 turns and retains the existing fact for every older ID. For example, late file-diff evidence written to a finalized turn after more than 64 newer turns leaves useSessionDiffSummary permanently stale until the view is recreated; older permission, scheduling, and goal facts have the same problem. Invalidate the affected fact and schedule hydration/re-derivation rather than assuming every index event is an append.
AGENTS.md reference: packages/components/src/lib/conversation-view/AGENTS.md:L29-L32
Useful? React with 👍 / 👎.
…nges Two defects in the per-turn fact table, both only reachable once a turn has been evicted: The view reported nothing when a change landed under a turn nothing holds hydrated. A `fileDiff` or `modelInfo` write touches no index scalar and cannot be patched into a turn object that does not exist, so `handleBatch` fell through silently. Any consumer caching something derived from that turn kept its stale value until the whole view was rebuilt — `useSessionDiffSummary` would show a diff summary missing a later file diff on an older turn. The view now reports the index for those turns too, so a cache can see it is stale. `createConversationDerivation` then acts on that signal: a changed range is re-derived when hydrated and DROPPED when not, with the background pass restarted so the fact is derived again instead of disappearing. The drop is driven only by explicit change events; the speculative tail window keeps deriving without dropping, or every index event would discard the facts of every turn past the hydrated tail. The pass also leaked its hydration pin: `ensureRange` pins before its first await, and disposing the derivation mid-chunk returned without releasing. The view outlives the consumer in the warm store cache, so those turns became permanently un-evictable and the hydrated set grew past `maxHydrated` — the resident-memory regression this module exists to remove. The release now runs in a `finally`. Both regressions are covered in tests/conversation-derivation.test.ts, and both fail without their fix (the leak leaves 10 turns hydrated against a cap of 6). Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ffa2915a82
ℹ️ 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".
| }); | ||
| return useMemo(() => { | ||
| if (!view) return { turns: EMPTY_TURNS, from: 0 }; | ||
| const next = collectHydratedRange(view, from, to); |
There was a problem hiding this comment.
Collect the hydrated suffix of the tail
When the last 20 turns exceed the item budget, createConversationViewFromDoc hydrates them newest-first and can leave the slot at from unhydrated; this forward collector then stops at that first gap and returns an empty or stale prefix even though the newest turns are already available. session-chat-interface.tsx uses this result for active-turn, configuration, activity, and app-review logic, so a fast sync can baseline the review hook without the historical completion and later treat that completion as new, potentially showing StoreKit merely from reopening a completed session. Collect the contiguous hydrated suffix ending at to, or withhold readiness until the entire requested tail is hydrated.
AGENTS.md reference: packages/components/src/hooks/AGENTS.md:L74-L79
Useful? React with 👍 / 👎.
… the control-plane Mirror Two contracts the `history: Ignore` schema and the doc facade change the mechanics of, neither covered by a test until now. An unknown root written by a peer on a newer schema is no longer discovered at construction — the facade answers root enumeration with nothing, so a root that never changes during the session stays out of Mirror state. The part providers/AGENTS.md actually requires still holds and is now asserted: a control-plane write neither deletes nor rewrites it. A stray `setState` touching `history` is skipped on write, so the durable list is untouched and the view still reports the real turns; the value lingers only in that Mirror's memory, which is what `schema.Ignore` means and what `SessionDocState` (omitting `history`) keeps anyone from reading back. Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 701a3ad916
ℹ️ 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".
| if (change.kind === 'index') { | ||
| if (view.turnCount < lastTurnCount) changed = pruneRemoved() || changed; | ||
| lastTurnCount = view.turnCount; | ||
| // Appended turns land in the hydrated tail; derive whatever is there. | ||
| changed = deriveHydratedRange(Math.max(0, view.turnCount - 64), view.turnCount) || changed; |
There was a problem hiding this comment.
Restart the fact pass after bulk appends
The added range invalidation fixes edits to existing turns, but structural batches still take this index branch: when an open session receives more than 64 turns at once—such as the single bulk append in apps/cli/src/lib/local-project-history-sync-service.ts:828-829—only the final 64 positions are considered, and this path neither calls requestPass() nor clears complete. Any earlier newly appended turns therefore never receive facts, so whole-history consumers such as fallback diff summaries, goals, and scheduled tasks permanently omit them until the view is recreated. Restart the background pass whenever the turn set changes.
AGENTS.md reference: packages/components/src/lib/conversation-view/AGENTS.md:L29-L32
Useful? React with 👍 / 👎.
…ced invariant
`SessionDocState` omitting `history` and `SessionDocUpdater` taking a draft
without it already make every ordinary second path a compile error: reading
`getState().history` is TS2339, an unannotated `setState` callback is TS7006,
and the pre-change `(draft: SessionDocMeta)` annotation is TS2345. What types
cannot close is a deliberate escape, so the guard now covers those instead of
only the literal spellings, and covers all of `src` rather than
`src/components`:
- a cast that puts the key back — `(draft as { history }).history.push(...)`,
`(getState() as SessionDocMeta).history`, `draft.history = [...]`
- reaching past the store into the raw document — `getList('history')`
- the `SessionDocMeta` annotation the updater no longer accepts
Block comments are stripped and unrelated `history` receivers (the browser's,
a local project's import catalog) are blanked, so prose and neighbouring
features do not trip it. `lib/conversation-view` and the rollback branch that
builds its adapter stay exempt, and that exemption list is asserted to be
exactly the set of modules that need it — a stale entry fails too.
The rule itself now lives in components/src/AGENTS.md, where hooks, providers,
atoms and components can all find it; ai-gui points at it.
Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7bec90e22a
ℹ️ 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".
| if (anyChanged || allBlocks.length !== lastBuiltLengthRef.current) { | ||
| setBlocks(allBlocks); | ||
| lastBuiltLengthRef.current = allBlocks.length; | ||
| const unsubscribe = view.subscribe(scheduleRebuild); |
There was a problem hiding this comment.
Hydrate newly inserted turns while search remains open
When history grows after the one-time hydration loop has finished, this subscription only rebuilds from turns that are already hydrated. A bulk append such as apps/cli/src/lib/local-project-history-sync-service.ts:828-829 can add more turns than the view's hydrated tail retains, so the earlier inserted turns remain absent from search results until the user closes and reopens search. Handle structural view changes by pinning and hydrating the newly added range before rebuilding the index.
Useful? React with 👍 / 👎.
…from the conversation view Cleanup pass over the windowed-history change. No behaviour change; the whole components suite and the guard test are unchanged and green. Reuse: - `summarizeTurn` now calls the outline's own `firstTextOf` / `proseLengthOf` and shares `SUMMARY_SOURCE_WINDOW` instead of redeclaring the 960-char window as `TURN_SUMMARY_HEAD_CHARS`. Two definitions of "prose" had to stay byte-identical or a round's title and tick weight would change the moment its turn was evicted. - `resolveActiveAssistantTurnIdFromIndex` delegates to `@lody/shared`'s `resolveActiveAssistantTurnId`, so "active" keeps one definition. - The rAF coalescer existed three times (view, derivation, search); one `subscribeOnFrame` now takes any `subscribe(listener)` source. - `isRecord` existed five times; one `isPlainRecord`, with the materializer keeping its stricter loro-mirror-compatible variant. - The chat-stream-items cache uses the shared `LRUCache`. - `CONTROL_PLANE_IGNORED_ROOT_KEYS` is read off the schema's `Ignore` fields, so the doc facade cannot drift from the schema it fences. Simplification: - `deriveHydratedRange` and `refreshRange` were the same walk twice; one `deriveRange(from, to, dropStale)`. - `runIdleChunk` re-inlined `withHydratedFacts` and `fillRowCounts`; it calls them, and its tail pre-pass is dropped because the backwards main loop reaches those rows first with the same result. - The items/plan count branches differed only by key name. - `TurnIndexRow` is derived from `INDEX_SCALAR_KEYS` rather than repeating the eleven keys in a third place. - Dead surface removed: `useConversationView` (no callers), `summarizeTurnShallow`'s unused `doc` parameter, the `idleChunkSize` / `idleItemBudget` options nobody passed, seven barrel exports with no importer, and the placeholder row cache (its consumer memoizes on `item.row`, which is already stable). Efficiency: - `rebuildLookups` cleared and rebuilt the whole id map per list delta; appending to a 2,400-turn conversation now touches one entry. - `resolveTurnIndex` trusts `event.path[1]` unless the same batch moved turns, which retires a descendant→turn cache that grew to 100k entries per session and was then cleared wholesale. - `respondPermission` no longer allocates an N-element candidate list, and the permission card passes the turn id it already knows, so the common path is one lookup instead of a scan. Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c43b62a29
ℹ️ 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".
| let turnBlocks = cache.get(turn); | ||
| if (!turnBlocks) { | ||
| turnBlocks = extractSearchBlocksForMessage(turn, i); | ||
| cache.set(turn, turnBlocks); |
There was a problem hiding this comment.
Recompute cached search indices after structural edits
When a turn is inserted or deleted before an already indexed turn while search remains open, the surviving hydrated turn keeps the same object identity, so this cache reuses blocks whose embedded messageIndex still points to the old position. Search navigation passes that stale index to scrollToIndex, causing results after the structural edit to jump to and highlight the wrong turn; cache entries need to include or refresh the current index.
Useful? React with 👍 / 👎.
…nderer `pnpm typecheck` failed in `@lody/history-import`, whose benchmark imports this module by relative path. Two causes, both introduced by the cleanup pass: - `turn-summary.ts` and `types.ts` reached the outline through the `@/` alias, which only exists in the components tsconfig. The module is consumed cross-package, so it now imports siblings relatively; the note in the module's AGENTS.md says why, and `projected-conversation-view.ts` is converted too rather than left as the next trap. - `conversation-outline.ts` imported `TurnIndexRow` from the conversation-view BARREL, so `types.ts` -> outline -> barrel closed a cycle and dragged `feature-flag.ts` — which reads `import.meta.env` — into a compilation with no Vite types. It imports the module file instead. Verified with the whole workspace typecheck this time, not one package: `pnpm typecheck`, `pnpm format:check` and `pnpm check:quick` all pass, components 428 files / 3072 tests and history-import 33 tests green. Model: claude-opus-5[1m]
Summary
Opening a conversation no longer materializes its history. The session store builds a control-plane Mirror whose
historyisschema.Ignore(), and the renderer reads turns through a windowedConversationViewthat hydrates only the viewport (plus two screens each side) and the streaming tail. History writes go through aHistoryWriterwhose container shape is byte-identical to the Mirror writes it replaces. No data migration: the doc format is unchanged, and the old path stays behind a rollback flag for one release.Base:
session/8336142a. Built against today's loro-crdt APIs (getShallowValue,getContainerById().toJSON(),doc.subscribe), with the mapping to loro-mirror's upcomingLazyListdocumented inlib/conversation-view/AGENTS.md.What changed
packages/components/src/lib/conversation-view/(new)createConversationViewFromDoc: index rows from one shallow read per turn (item/plan counts cost two more container crossings each and land with the summary instead), tail hydrated eagerly within an item budget, LRU (maxHydrated200) exempting pinned ranges and the last 20 turns, summaries and shallow user config filled in idle chunks (ready), doc events applied incrementally (copy-on-write patch, full re-read fallback).HistoryWriter(append/replace/respondPermission/read) over Loro container APIs, driven by the shared schema with loro-mirror's inference rules restated (history-materializer.ts).createControlPlaneDoc+sessionControlPlaneSchema: the Mirror never seeshistoryevents (loro-mirror's event path applies ignored roots) and skips root enumeration (a ~35 ms lazy-snapshot walk on 2,000 turns).createConversationViewFromHistory(rollback adapter),createProjectedConversationView(accepted-projection overlay withprojectAcceptedSessionHistoryplacement),createConversationDerivation(background per-turn fact table for whole-history readers).SessionDocStore.history/historyWriter;SessionDocStatehas nohistory.createSessionStorepicks the path fromisConversationViewEnabled()(envLODY_CONVERSATION_VIEW=0or the Developer-mode switch).SessionChatStreamtakesview.buildChatStreamItems(view, …)yields one item per turn (message or placeholder with the index row), rows/outline/scrollToIndexuse absolute turn indexes,TurnPlaceholderRowrenders non-hydrated turns sized from the row summary, and the viewport reports its turn range (findItemIndex) to drive hydration with ±2 screens prefetch.shift={false},leadingContentas a real row,scrollRowToTop, and the outline correction loop are untouched.grep "getState().history" / sessionDoc.historyunderpackages/components):ai-gui/index.tsxlast user message → index.hooks/use-task-actions.ts,hooks/use-session-actions.ts(dispatch, steer, pending_apply promotion),hooks/use-session-doc.ts(updateHistoryEntry,hasLocalHistory) →historyWriter.read/replace.hooks/use-remove-local-project.ts,sessions/managed-preview-surface.tsx→resolveActiveAssistantTurnIdFromIndex.providers/workspace-writer-impl.ts(startSession,appendSessionTurn,appendSessionHistory,updateSessionHistory,respondSessionPermissionnow takes an optionalturnId) →historyWriter.sessions/session-chat-interface.tsx: conversation config / source fence / runtime config, activity, active turn id, context compaction, unstarted dispatch, editable last user, capacity retry, pending permissions, app-store review → hydrated tail (useConversationTail, extended to the last user turn); billable turn count, end-timing analytics, search analytics → index rows; scheduled tasks, goal, latest proposed plan, permission funnel →useSessionTurnFacts; search → hydrate-while-open (useIncrementalSearchBlocks); export → hydrate on demand and release; pin →useTurn; scroll targets →indexOf.sessions/draft-session-chat-interface.tsx(parent config),sessions/session-detail.tsx(fork-origin notice is the last entry → tail),sessions/use-session-diff-summary.ts(fallback diff inputs → derivation).apps/cli: unchanged writes;SessionDocument.getHistory()documented as full materialization reserved for import hashing and dispatch scans.tests/no-materialized-history-in-components.test.tsfails on anysessionDoc.history/getState().historyread undersrc/components;ai-gui/AGENTS.md,providers/AGENTS.md, and the newlib/conversation-view/AGENTS.mdrecord the invariants.bench:opengainsopen,open+idle,scroll,stream,stream(Mirror)and runs on the synthetic fixture by default (a desensitized real fixture is used locally only).Sessions/ConversationViewstory renders 3,000 turns through a doc-backed view.Tests
conversation-view-from-doc.test.ts: index/hydration equal Mirror output, LRU, chunked hydration, streaming patches, appends/deletes, idle pass, dispose.history-writer.test.ts:appendproduces the same ops, snapshot bytes, and container shape asMirror.setState;replaceandrespondPermissionmatch the Mirror draft mutation shape and the old full-Mirror read path.control-plane-mirror.test.ts:new Mirroron a 2,000-turn doc < 30 ms (measured ~1 ms median) and never materializes history while still seeing other roots and unknown roots.apply-turn-event.test.ts: deterministic random-op property test againsttoJSON().turnIndex.Numbers
M-series laptop,
pnpm --filter @lody/history-import bench:open. Machine load was ~4–5 throughout (other work on the same box), so treat these as an upper bound rather than a clean-room figure.Synthetic fixture (committed,
benchmarks/fixture.ts)--scale=1,10 --iterations=10 --baseline-iterations=3Mirror(today's open)open(view + tail hydrate → renderable rows)open+idle(…plus the whole background index pass)scroll(ensureRangeof 30 turns, ×20)stream(100 text deltas on the tail turn)stream(Mirror)(same deltas, full Mirror subscribed)Acceptance:
open≤ 50 ms at x10 → 48.1 ms mean (p99 57.0).streamp99 ≤ 4 ms → 0.04 ms, ~370× under.The x10
Mirrorrow is reported as 93.5 s mean over 3 iterations, which is not ausable "before" figure: building 2,400-turn Mirrors back to back thrashes the
heap and the number reflects GC, not the operation. Measured in isolation
(median of 5, fresh doc each time) the same construction is ~3.8 s — the
honest before/after pair at x10 is therefore ~3.8 s → 48 ms, and the
per-token cost is 14.9 ms → 0.01 ms.
Desensitized real fixture (local only, never committed)
171-turn / 5,087-item conversation captured with
bench:capture,--scale=1:Mirror3.50 s mean (5.47 p99)open34.7 ms mean (40.3 p99)stream(Mirror)6.7 ms mean, 7.2 p99stream0.02 ms mean, 0.11 p99Where the x10 open cost goes (median of 5, fresh doc each)
history.getShallowValue()(2,400 container ids)getContainerByIdper turnmap.getShallowValue()per turntoJSON())items/plan(removed; idle pass fills counts)lengthper turn+16.7 msnew Mirrorwithhistory: schema.Ignore()over the same 2,000-turn doc is ~1 ms (asserted < 30 ms incontrol-plane-mirror.test.ts).Invariants I could not preserve as-is
inputConfig, which the view fills in idle chunks after open. On a very long conversation a Role pinned only on an old turn can resolve as absent for a few hundred milliseconds after open, then appear. The latest user turn's full config is always available synchronously.<id>→assistant:<id>:…); user and system rows keep the same key. Virtua takes the same path as group expansion;shift={false},leadingContentas a real row,scrollRowToTop, and the outline correction loop are unchanged.new Mirror< 30 ms is asserted with wall-clock timing because the task asks for it; the measured median is ~1 ms so the bound is not near noise, but it is the one timing assertion in the suite.itemCount(before the idle pass reaches a turn) reads as unknown, not zero.isEmptyAssistantIndexRowtreats unknown as "not empty" so a real turn is never dropped, which means an interrupted empty assistant turn far up the history renders a placeholder row for up to ~1 s after open. The conversation opens on the hydrated tail, so this only perturbs estimated scroll geometry above the viewport, never a visible row.LODY_CONVERSATION_VIEW=0/ developer switch) keeps the old full Mirror cost, per-token immer cost included; it exists for one release only.