feat: read long conversations by window with shared history writes - #376
Conversation
… open/import benchmarks - Move buildHistoryReplayImport, materializeReplay, refresh/conflict decisions, hashing and catalog helpers out of apps/cli and packages/shared into packages/history-import. The package is pure: clocks (nowIso, lastSyncAt) are injected, no IO, no Loro, no logger. - apps/cli local-project-history-sync-service keeps orchestration only; its pure-logic tests move with the code. - Add benchmarks: synthetic replay fixture, desensitizer for real local session docs, tinybench open-conversation (snapshot -> Mirror) bench, import bench, and a CLI script that captures a real ACP replay. - Root AGENTS.md project map lists the new package. Model: claude-fable-5-1
…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]
Model: gpt-6-astra
Extract the schema-aware HistoryWriter from #376 and route renderer and CLI history changes through one shared path. Parse new and changed inputs before CRDT operations while preserving untouched incompatible history and existing containers. Keep the full Mirror read path, add compatibility and compile-time regressions, and document the contract. Model: gpt-6
* fix: preserve future history items when sending messages Model: gpt-6 * docs: scope session history invariant to shared package Move the history storage rule next to its schema and tests so the root AGENTS.md stays within the documentation CI size limit. Preserve the rule and add the required CLAUDE.md symlink. Model: gpt-6 * docs: require agent notes for non-trivial work Clarify note triggers for implementation, research, and design; preserve read-only task restrictions and existing note ownership. Record the policy in bilingual notes and link the repository map to keep root instructions within budget. Model: gpt-6 * docs: consolidate documentation workflow in root instructions Make reading, Spec approval, Agent Note updates, and completion checks explicit in root AGENTS.md. Move catalog explanations into a linked guide while preserving binding rules and update the bilingual process note. Model: gpt-6 * fix: temporarily bypass session whole-state validation Disable session Mirror update validation in the renderer and CLI while preserving external input parsing. Cover malformed historical items and both construction sites, and document the temporary tradeoff and replacement contract. Model: gpt-6 * fix: centralize session history writes behind typed boundaries Extract the schema-aware HistoryWriter from #376 and route renderer and CLI history changes through one shared path. Parse new and changed inputs before CRDT operations while preserving untouched incompatible history and existing containers. Keep the full Mirror read path, add compatibility and compile-time regressions, and document the contract. Model: gpt-6 * fix: accept fork origin notices in history writes Add the correlated fork-origin schema and bidirectional notice type coverage. Exercise regular and worktree forks through the real SessionDocument and HistoryWriter; retain malformed metadata rejection and document the prior test gap. Model: gpt-6 * fix: preserve stored history across fork and rollback Separate writer-authored input from writer-captured stored history. Preserve opaque content during forks and guarded edit rollback, with real Loro integration coverage. Model: gpt-6 * fix: preserve history across initialization and read acknowledgements Retain fork setup logs, allow replacement read acknowledgements during rollback, and validate tool state changes without reparsing untouched payloads. Model: gpt-6 * chore: move documentation maintenance changes to PR 466 Keep HistoryWriter-specific contracts and repair notes here; the general documentation workflow is reviewed independently in #466. No runtime code changes. Model: gpt-6 * fix: preserve steer provenance and permission metadata Preserve steer delivery markers through history writes and reads, and validate permission metadata updates independently of legacy tool payloads. Add real-writer regression coverage and update the related specs and notes. Model: gpt-6 * fix: track stored baselines for imported history Keep source hashes and turn identities stable while validating refreshed history against a cursor-bound stored-content baseline. Cover legacy storage, concurrent edits, delayed cursors and conflict resolution with real writer regressions, and document compatibility limits. Model: gpt-6 * fix: preserve peer edits when resolving task proposals Resolve task decisions against the latest history item through HistoryWriter instead of replacing stale entries. Add deterministic replica regressions and document the remaining structural-edit limit. Model: gpt-6 * fix: isolate invalid ACP history inputs and preserve extensions Accept JSON protocol extensions without relaxing known message validation, preserve unchanged legacy tool blocks, and isolate deterministic write failures from subsequent ACP output. Add nested schema contracts, regressions, and implementation notes. Model: gpt-6 * fix: scope history rollback to the changed range Preserve concurrent edits to untouched history rows during edit-and-resend compensation. Add real replica and service regressions and update rollback contracts and notes. Model: gpt-6 * perf: reduce history writer parsing and field update overhead Index discriminated schemas, avoid redundant copies and unchanged-container reads, narrow field updates, and batch initial history writes. Add synthetic benchmarks and document the remaining full-Mirror performance gap. Model: gpt-6 * perf: use target-local history updates for ACP streaming Route targeted text and thought batches through turn-local writes, derive cached single-pass input parsers with refinements retained, and reduce item and stored-copy matching overhead. Add compatibility regressions and benchmarks documenting remaining seed and commit costs. Model: gpt-6 * perf: copy only changed paths for Mirror text events Patch the pinned Mirror reader for single existing text updates while preserving descriptors, snapshots, and notifications. Add real-replica regressions, isolated reader benchmarks, and documentation of remaining bulk-import limits. Model: gpt-6 * fix: preserve queued messages and compatible history edits Normalize legacy selectors on new writes, validate independent field edits locally, retain queue items until history acceptance, and capture only the rollback range while preserving later appends. Add regressions and document remaining recovery limits. Model: gpt-5 * chore: consolidate history writer docs and remove shallow tests Consolidate incremental notes into the bilingual owning record, repair references, remove source-string construction tests, and require behavior-focused tests and compact PR documentation in AGENTS.md. Model: gpt-5 * fix: repair queued turn activation on promotion retry Retain queued messages until history and activation publication succeed. Recover history-only commits without duplicating turns, replaying terminal messages, or replacing another pending activation. Cover metadata failures with a real SessionDocument regression and update the owning documentation. Model: gpt-5 * fix: preserve history input filtering through Zod pipes Traverse both sides of schema pipes while preserving preprocessing, transforms, and refinements. Cover nested config filtering, legacy normalization, stored-history preservation, and invalid-write atomicity with real writer tests. Model: gpt-5 * chore: adopt loro-mirror 2.3.2 Use the released Mirror text-event optimization and remove the temporary 2.3.1 patch. Update the paired React/Jotai packages and lockfile, and record the upstreamed reader path in the owning notes. Model: gpt-5
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dfe826410a
ℹ️ 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 (!list) return undefined; | ||
| for (let index = list.length - 1; index >= 0; index -= 1) { | ||
| const value = list.get(index); | ||
| if (!isContainer(value) || value.kind() !== 'Map') continue; |
There was a problem hiding this comment.
Handle inline rows when selecting the plan target
When a resumed/upgraded session's newest assistant turn uses the supported legacy plain-JSON representation, this separate assistant lookup skips it and either selects an older container-backed turn or returns no id. handleACPUpdateMessage sends every latest ACP plan snapshot through doc.setPlan, so recovery of an inline assistant can durably attach the live plan to the wrong turn or silently omit it; inspect plain record rows for role and id here as the reader and writer already do.
AGENTS.md reference: packages/shared/src/session-data/AGENTS.md:L90-L92
Useful? React with 👍 / 👎.
Remove the unused history-import workspace and its capture/dump tooling. Keep a focused synthetic benchmark over the production reader and window hydration. Model: gpt-6
Delete the unused raw-doc view, migrate its useful regressions to the shipped reader, and invalidate derived facts with explicit changed turn IDs. Keep only a build-time rollback and use one authoritative full read for exports. Model: gpt-6
…venance Keep CLI execution methods with SessionDocument, reuse shared planners, remove the test-only memory backend and unused receipts/durability hooks, and let captured HistoryWriter snapshots survive source disposal. Private Mirror access replaces the bespoke AST guard. Preserve real storage, concurrency, compatibility and fork regressions. Model: gpt-6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1ae04afdf4
ℹ️ 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 findLatestPendingUserTurn = async (data: SessionData): Promise<SessionTurn | undefined> => { | ||
| const count = await data.history.count(); | ||
| for (let position = count - 1; position >= 0; position -= 1) { | ||
| const read = await data.history.readAt(position); |
There was a problem hiding this comment.
Avoid materializing assistant bodies in the auto-read scan
With auto-read attached, every streaming history commit invokes this scan, and readAt() materializes each trailing turn with LoroMap.toJSON() before the loop reaches the latest user row. During a long assistant response, token batches therefore repeatedly parse the ever-growing assistant body even when that user is already seen, creating quadratic work and potentially stalling ordinary sessions independently of session output --wait; locate and status-check the latest user through readDirectory() and materialize only that user when necessary.
Useful? React with 👍 / 👎.
| @@ -0,0 +1,2 @@ | |||
| /** Temporary build-time rollback. Remove with the array adapter after two releases. */ | |||
| export const WINDOWED_CONVERSATIONS = import.meta.env.LODY_CONVERSATION_VIEW !== '0'; | |||
There was a problem hiding this comment.
Expose the rollback constant to the renderer build
In the checked OSS Electron build, apps/electron/electron.vite.config.ts sets envPrefix to __LodyPublicBuildOnlyPrefix__ and its explicit define helper admits only VITE_* keys, so setting LODY_CONVERSATION_VIEW=0 leaves this property undefined and the expression always selects the windowed reader. The documented two-release rollback therefore cannot be activated for the shipped desktop build; explicitly inject this constant into the renderer build or use an admitted build-only prefix.
AGENTS.md reference: packages/components/src/lib/conversation-view/AGENTS.md:L22-L24
Useful? React with 👍 / 👎.
Use one shipped window reader and identity change events. Read CLI history synchronously, keep auto-seen on shallow scalars, and let stored permission outcomes win same-stack auto-approval. Ordinary writes propagate errors; import and editable-tail replacement retain commit-phase outcomes. Share the goal and diff fact table, remove forwarding modules, and test the single Loro implementation directly. Model: gpt-6
Preserve Stop/undelivered-steer coverage with the session history port fixture and retire tests for main’s removed auto-prompt flow. Model: gpt-6
Restore scroll position before paint and follow the DOM bottom without retaining a virtual row index that hydration can invalidate. Model: gpt-6
Add a deterministic Node replay and profiling harness for long-conversation performance checks. Keep local snapshots and profiling artifacts out of the repository. Model: gpt-6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ede159a182
ℹ️ 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 (released) return; | ||
| released = true; | ||
| pinIds(capturedIds, -1); | ||
| evict(); |
There was a problem hiding this comment.
Notify subscribers when release evicts hydrated turns
When a whole-history lease is released—for example, when search closes after hydrating a 3,000-turn conversation—evict() removes the unpinned bodies without bumping the view version or emitting a change. useConversationStreamItems therefore remains memoized on its fully hydrated items, and its BuildChatStreamItemsCache continues retaining each turn through rawEntry; a completed session can remain fully materialized indefinitely despite the view reporting those turns as evicted. Publish a cache change whenever release-triggered eviction removes bodies so consumers rebuild with placeholders.
AGENTS.md reference: packages/components/src/lib/conversation-view/AGENTS.md:L5-L10
Useful? React with 👍 / 👎.
Keep static share publication and route shared snapshots through the ConversationView adapter. Adapt publication reads and sharing regression fixtures to the session history API. Model: gpt-6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 75ffc7bf0b
ℹ️ 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".
| isActive: () => boolean = () => true | ||
| ): Promise<{ turn: SessionHistoryInput | null; history: SessionHistoryInput[] }> { | ||
| const history = await sessionDoc.getHistory(); | ||
| const history = readSessionHistory(sessionDoc.sessionData.history); |
There was a problem hiding this comment.
Avoid rematerializing history for every watched update
While a session has pending or processing work, the active-watch subscription enqueues a session check for every streaming history commit, and this line now makes each check call readAll() → LoroList.toJSON() over the entire transcript. Thus normal assistant output—not only session output --wait—does O(history size × update count) parsing and allocation; long conversations can stall the CLI and delay output persistence or dispatch. Use directory/targeted reads for reconciliation or ignore body-only events.
AGENTS.md reference: apps/cli/src/session/AGENTS.md:L42-L43
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c37ad98f14
ℹ️ 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 userId = latestAssistant?.userId; | ||
| if (!userId) { | ||
| const history = await sessionDoc.getHistory(); | ||
| const history = readSessionHistory(sessionDoc.sessionData.history); |
There was a problem hiding this comment.
Avoid full-history reads for each usage event
For providers that emit usage updates during a turn, this fallback materializes and clones the complete transcript for every event via readAll(). Normal assistant entries created by createAssistantTurn do not contain userId, so this is the usual path rather than an exceptional fallback; long conversations can repeatedly stall the CLI and delay the pending-usage drain during finalization. Resolve the linked user through latestAssistant.userTurnId and a targeted readTurn instead.
Useful? React with 👍 / 👎.
| const legacyMeta = meta as SessionLegacyMetaFields | null | undefined; | ||
| const historyGoal = resolveLatestSessionGoalFromHistory(await sessionDoc.getHistory()); | ||
| const historyGoal = resolveLatestSessionGoalFromHistory( | ||
| readSessionHistory(sessionDoc.sessionData.history) |
There was a problem hiding this comment.
Keep GC probes from materializing every candidate history
When the periodic or memory-pressure GC evaluates idle sessions, session-gc-manager.ts calls hasActiveGoal for every candidate and again immediately before cleanup. This full readAll() projection materializes and clones every turn body, so a sweep over several long cached sessions creates a large allocation spike precisely while the process is trying to recover from memory pressure, potentially preventing eviction or causing an OOM. Use an incremental goal index or a bounded one-row-at-a-time scan instead.
AGENTS.md reference: apps/cli/src/lib/AGENTS.md:L58-L64
Useful? React with 👍 / 👎.
Keep the initial window lease until ready and follow committed virtual row geometry before paint, preserving explicit scroll intent. Model: gpt-6
Preserve the caught React component stack in custom fallbacks and expose a copyable error report without resetting the composer. Model: gpt-6
Preserve windowed row indices and ordered share capture; adapt new main fixtures to the session reader. Model: gpt-6
Condense existing scrolling and resource lifecycle rules without changing their requirements. Model: gpt-6
Wait for the rendered scroll destination rather than a scrollTop assignment. Observe mounted row geometry before paint and preserve the follow lock during layout corrections. Cover cold open and remount with a gated browser measurement regression. Model: gpt-6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c75e76eb0
ℹ️ 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 id = readIdentity(list.get(i))?.turnId; | ||
| if (id !== undefined) ids.push(id); | ||
| } | ||
| listener({ kind: 'changed', ids }); |
There was a problem hiding this comment.
Treat turn-ID edits as structural changes
When a synchronized older/raw peer renames or removes a turn's id in place, this branch emits only the post-change ID (or an empty list). The ConversationView is still indexed by the old ID, so it cannot resolve that notification to a position and never refreshes the directory row or cached body, leaving the transcript permanently stale until the view is recreated. Classify identity edits as structural changes so the position is re-keyed.
AGENTS.md reference: packages/shared/src/session-data/AGENTS.md:L13-L17
Useful? React with 👍 / 👎.
…ns (#695) * test(components): reproduce the conversation-open blank flash Doc-backed stories that mount `SessionChatStreamView` over a warm `ConversationView`: the LoroDoc, the view and every `HistoryWriter.append` happen while the stream is unmounted, so opening or switching costs exactly what the sidebar costs on an already-loaded session. `OpenLongConversation` toggles one conversation; `SwitchBetweenLongConversations` holds two warm 3,000-turn conversations under distinct session ids, because the reading position and stream-item caches are per session. `e2e/scripts/capture-conversation-open-flicker.mjs` drives them under Playwright and samples the pane every animation frame — viewport presence, computed visibility, committed row count, scroll offset — plus a screen recording. CPU throttling stretches the flash past the recording's frame period. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(components): restore Virtua row measurements when a session reopens Opening a long conversation showed an empty pane for ~55 ms on a production build, longer on a slower machine. The cause is not #376's visibility gate: ablating it kept the pane visible and the row set still emptied before it refilled, now also exposing the uncorrected scroll position. The cost is the cold virtualizer. With no measured row heights it lays a 3,000-turn conversation out at an estimated total height, writes the restore offset into that wrong coordinate space, and only corrects once the first rows are measured; the viewport stays hidden across those commits. Virtua's measurements are now cached per session next to the reading position and handed back through `Virtualizer.cache`, so a reopen lays out at the real height and reveals a commit earlier. Two guards: the snapshot is positional, so it is only restored when the row count is unchanged, and it is stored when the initial layout settles and after scrolling stops, never at unmount — React detaches the virtualizer ref before cleanup effects run. Switching between two warm 3,000-turn conversations: 54 ms blank before, 33 ms after. A first, uncached open is unchanged, and the remaining blank is the one commit Virtua needs before it knows its viewport size. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(components): wait for real rows before consuming the row snapshot The stored measurements were read on the first render with a positive item count, which on the shipped session path is the wrong render. A session whose document is still being acquired returns from the empty-state branch before `Virtualizer` mounts, yet every hook above that return has already run, and the session page's always non-null leading fragment counts as one row. The lookup therefore answered for a one-row list, latched, and never asked again for the real conversation — the blank flash stayed on the path the previous commit set out to fix. `useStickyScroll` now takes `hasVirtualizedRows` and reads the snapshot only when the caller is about to mount the virtualizer. The story missed this because it passed no `leadingContent`, so its empty phase reported zero items and the latch never fired early. It now renders the empty state on every switch and supplies the same non-null fragment the session page does. Switching between two warm 3,000-turn conversations on that faithful path: 54 ms blank on main, 57 ms with the read ungated, 17-35 ms with this fix. Reported by the automated reviewer on #695. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Related issue
Same-repository maintainer work; no separate tracking issue.
Problem / pressure
Opening a long conversation should prepare the visible messages, not materialize every history body before the UI can use them. On main, DOM virtualization happens after
createSessionMirrorhas already read the full history. This PR moves that boundary down to history reads.Summary
One production
ConversationViewreads a shallow directory and hydrates requested windows. The existing sharedHistoryWriterremains the only history writer. The renderer uses the history reader; CLI operations use synchronous in-process reads and the same write rules. This is a windowed-read change, not a new CRDT or a complete replacement-storage implementation.Local 1000-round comparisons: on the 140 MB high-entropy sanitized workload, cold data preparation falls from 157.3 seconds to 317.7 ms (median, 3 fresh processes per revision, including import, initial bodies, message rows and outline). A smaller synthetic workload measures 121.4 ms → 33.2 ms for import + reader preparation. These are different workloads and endpoints, not browser first-paint measurements; see the details below.
Visual explanation
Before: virtualize the DOM after reading all bodies
Now: separate session metadata, directory and requested bodies
The directory remains O(total entries). Complete bodies are loaded for the visible/retained range, with an LRU target of 200 and a retained tail of 20; pinned windows can exceed the target. Outline counts do not require all summaries: opening a hover card reads that question and its replies, then releases the lease. There is no full-history outline-summary sweep.
Streaming: invalidate by identity, refresh the affected bodies
Repeated
readTurncalls also reuse the identity index while a pending transaction is unchanged. A pending transaction alone no longer forces a full ID scan on every body read; new operations, identity/structure edits and checkout still invalidate it. Reads never commit the document.Ownership and compatibility
create-conversation-session.tssession-data/loro.tscreate-conversation-view-from-reader.tsderivation.ts+deriveSessionTurnFacts; consumers share one table per viewHistoryWriter+ business planners; unchanged opaque/legacy data survivesreadAll, never stitched display windowsThe old raw-doc view, test-only memory backend, storage capability registry and runtime window-mode fallback are gone. The array adapter remains for static shared pages, not as an app fallback. Detached fork snapshots reuse the writer's provenance and survive source unload. Stored data is not migrated. Import and editable-tail replacement retain commit-time checks and conditional compensation.
Before / after
Local benchmark
Compared main
f3c399fewith PR75ffc7bf, on macOS arm64 / Node 22.23.1, with each checkout's frozen lockfile. Five fresh processes per revision, alternating main/PR order; no simultaneous benchmark jobs.Input: deterministic synthetic 1000 user rounds = 2000 entries, seed 376, generated by the committed replay worker through
HistoryWriter. Each round includes a user message, thought, 4096-character tool output and assistant answer with high-entropy text. Both revisions import the same 12,687,300-byte snapshot, SHA-25619794e069e956201613460c1844bcae4e298f5b2f6a159f974cffb6001292d98.Reader/window raw samples (ms): main
110.50, 109.94, 120.69, 110.73, 112.58; PR22.95, 23.73, 22.86, 22.98, 23.43.Boundary: file reading, TS/module loading and fixture generation are outside timing. The main endpoint is
createSessionMirror(...).getState().history.slice(-30); the PR endpoint is directory readiness plusacquireRange(last30).ready. Both verify the returned IDs and item payloads against the stored tail. Idle callbacks are not drained and React/business derivation consumers are not mounted. This does not measure paint, Markdown, Virtua layout, scrolling, IPC, network, persistence, or device frame time.Larger high-entropy workload: complete cold data preparation
The earlier 120-second timeout was followed up with a longer per-process deadline. All three main and three PR runs completed. Same revisions as above (
f3c399fe/75ffc7bf), macOS arm64, Node 22.23.1,--expose-gc --max-old-space-size=8192; both checkouts use loro-crdt 1.15.1 / loro-mirror 2.3.2. Fresh processes run sequentially in alternating order.Input is the local high-entropy sanitized Storybook workload: 140,206,036 bytes, 1000 user rounds, 2671 history entries, reused unchanged on both revisions. The fixture and raw evidence remain local and are not committed or uploaded. These results describe this workload, not all 1000-round conversations.
Total samples: main 142.0 / 157.3 / 167.6 seconds; PR 317.7 / 392.2 / 305.4 ms. Totals include the common session-ID setup (~2 ms); medians of phases need not sum to the median total.
The main path calls the actual full
createSessionMirror, then the actual message-row and outline builders. The PR path callscreateConversationSession, waits for the directory and the product's initial window (last 40 entries, extended to the preceding user question: 42 bodies for this fixture), then the same row/outline entrypoints. Both produce 1000 outline entries. Main has 2663 message rows and PR 2671 because offscreen placeholders differ from full empty-assistant filtering.The measured main delay is inside full Mirror initialization, not the later outline builder. Outline construction consumes already-materialized JS data; it is not another independent CRDT
toJSON()pass. No internal CPU profile was taken to attribute the Mirror delay to a narrower algorithm.Measurement boundary: file I/O, module loading, fixture generation, React/Markdown mounting, browser layout/paint, network/IPC and background business-fact scans are excluded. Idle callbacks are disabled. RSS is an after-outline process sample, not peak memory, retained heap after GC or browser memory. This is a complete A/B of the stated data-preparation path, not end-to-end UI acceptance.
Reproduce the input and opening comparison
Generate the synthetic input once on this PR using the committed
benchmarks/replayworker; reuse the resulting snapshot for both checkouts. The full replay also covers window jumps, append, streaming and hover preview, and can emit CPU profiles.node packages/components/benchmarks/replay/run.mjs --rounds 1000 --runs 1 --mode committed --out /tmp/replay-1000 # Input for both revisions: /tmp/replay-1000/synthetic.snapshotFor the opening comparison, save the following as
packages/components/open-compare-worker.tsin both checkouts. Run in a fresh Node 22.23.1 process with--expose-gc --max-old-space-size=8192 --import <the checkout's tsx loader>; arguments aremain|prand the snapshot path. Alternate the modes across five repetitions. The benchmark uses a committed session-id initialization in both modes.Test plan
75ffc7bf: Tests and Desktop E2E (smoke) passed. Static checks failed on three unawaited promises inpackages/acp-extension-core/test/usage.test.mjs(lines 12, 18, 30). Main and this head pin the same core submodule80205d81; do not read this as an all-green CI claim. Current run.Remaining costs: Loro import, the initial directory and explicit full reads are still O(total). Goal/permission/diff derivations still read history in the background when consumers mount; lazy outline previews do not remove that business work. One enormous turn can still be expensive. Pinned ranges can exceed the body-cache target. No 3000-round desktop/mobile memory or frame-time acceptance is claimed.
Context handoff
Instructions for reviewing agents
Authoring context
Original user prompt