Skip to content

feat(components): wire ConversationView into the session store behind a flag (phase 1b-A) - #363

Open
zxch3n wants to merge 4 commits into
feat/reader-side-session-history-shapesfrom
feat/conversation-view-store-wiring
Open

feat(components): wire ConversationView into the session store behind a flag (phase 1b-A)#363
zxch3n wants to merge 4 commits into
feat/reader-side-session-history-shapesfrom
feat/conversation-view-store-wiring

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 1b-A of the long-conversation work (stacks on #359 and carries the phase 1a foundation commit 17ab602). createSessionStore now composes its state through providers/session-doc-state-source.ts:

  • Flag: isConversationViewEnabled() next to isElectronLocalDataPlaneEnabled — on unless VITE_LODY_CONVERSATION_VIEW=0 or localStorage['lody:conversationView']='0'. Off is the untouched full-Mirror path, kept as rollback.
  • Store: with the flag on, the Mirror is built with sessionControlDocSchema, SessionDocStore.conversationView exposes the ConversationView, and getState().history is a lazy bridge over conversationView.readAll(), memoized per (control root, view version) so prev === next identity checks keep working. subscribe fires on view changes too. use-session-doc.ts compares snapshots through readSessionDocHistoryRevision instead of touching history.
  • loro-mirror finding: 2.3.1 honors an Ignore root only in its initial snapshot; its incremental event path applied history deltas into a partial history array and registered every new container. createSessionControlMirror filters history events out of the two per-batch methods; pinned by a test that appends, replaces and streams text into history with the control Mirror attached.
  • Write guard: setState reaching history on the view path throws SessionHistoryWriteThroughMirrorError (object patch, mutative draft, returned object) instead of persisting nothing.
  • Writer routing: startSession, appendSessionTurn, appendSessionHistory, updateSessionHistory (indexOf hint) and respondSessionPermission (hydrated-tail hint) go through lib/conversation-view/history-writer when the store has a view. The steer-promotion status flip in use-session-actions uses the new patchHistoryEntry, which writes scalars without recreating item containers.
  • View extensions: retain(), planCount, per-turn fileDiff(), and an incremental readAll() that re-materializes only changed turns and keeps every other entry identity-stable (so identity-keyed render caches keep hitting through the bridge).
  • CLI: SessionDocument.getHistory() documented as full materialization reserved for import hashing and dispatch scans. No behavior change.

Converted readers (index/tail, no full hydrate)

use-session-doc (revision compare, updateHistoryEntry, useSessionDocSyncState), use-session-actions (dispatch/steer user-turn reads, promotion), use-task-actions.resolveTaskProposal, use-remove-local-project, managed-preview-surface, the fork-origin observer in session-detail, use-session-diff-summary (per-turn fileDiff containers), ai-gui/index.tsx lastUserMessageId, and session-chat-interface's active-assistant-turn and scroll-to-message reads.

Still on the bridge (sessionDoc.history, one incremental full read on first access)

session-chat-interface.tsx: conversation config / source fence / runtime config resolvers, capacity retry, the search block index (useIncrementalSearchBlocks), copy-as-markdown, latest Codex proposed plan, end-timing analytics, pin list, permission scans (scanPermissionRequests, FloatingPermissionRequest), activity-from-history, billable turn count; draft-session-chat-interface.tsx parent config; use-acp-session-config-selection. These are phase 1c.

Tests

tests/session-doc-state-source.test.ts (bridge, guard, control Mirror filter), tests/conversation-view.test.ts (retain, planCount, readAll identity), tests/conversation-view-turn-selectors.test.ts, tests/workspace-writer.test.ts (writer routing produces the Mirror's container shapes, never calls setState).

🤖 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.

…s and a Mirror-free history writer (phase 1a)

Opening a long conversation today materializes every history container through
the session-doc Mirror (357–521 ms on a 171-turn doc with 70k containers). This
adds the read/write primitives that let the renderer stop depending on that:

- `sessionControlDocSchema`: the session schema with `history` as an ignored
  root, so the control-plane Mirror (session, mq, preview, fork, runtime config)
  never touches the history list. Pinned by a 2,000-turn test.
- `createConversationViewFromDoc`: an O(window) read model over today's Loro
  APIs — turn ids and index rows from shallow values, ranges hydrated via
  per-turn `toJSON()`, LRU eviction that never drops the tail, subscribed or
  just-requested ranges, and doc-event tracking of appends, in-place updates
  and deletes. `readAll()` remains for deliberate full-transcript consumers.
- `HistoryWriter`: `appendHistoryEntry` / `replaceHistoryEntry` /
  `respondHistoryPermission` write straight into the doc with the exact
  container shapes a full-schema `Mirror.setState` produces (restated
  loro-mirror inference rules), proven by a getDeepValueWithID shape round-trip
  against the Mirror path.

Wiring into `createSessionStore`, the renderer's range-scoped rows and the
remaining full-history readers follow in phase 1b.

Model: claude-fable-5-1
… a flag (phase 1b-A)

`createSessionStore` now composes its state through
`providers/session-doc-state-source.ts`. With `isConversationViewEnabled()`
(on unless `VITE_LODY_CONVERSATION_VIEW=0` or
`localStorage['lody:conversationView']='0'`) the Mirror is built with
`sessionControlDocSchema`, the store exposes `conversationView`, and
`getState().history` becomes a lazy bridge over `conversationView.readAll()`
memoized per (control root, view version) so `prev === next` identity checks
keep working. Off is the untouched full-Mirror path, kept as rollback.

- `createSessionControlMirror`: loro-mirror 2.3.1 honors an `Ignore` root
  only in its initial snapshot; its incremental event path applied history
  deltas into a partial `history` array and registered every new container.
  The control Mirror filters history events out of the two per-batch methods,
  pinned by a test that appends, replaces and streams text into history.
- `setState` reaching `history` on the view path throws
  `SessionHistoryWriteThroughMirrorError` (object patch, mutative draft and
  returned-object shapes) instead of persisting nothing.
- Every history write goes through the history writer when the store has a
  view: `startSession`, `appendSessionTurn`, `appendSessionHistory`,
  `updateSessionHistory` (indexOf hint), `respondSessionPermission` (hydrated
  tail hint), and the steer-promotion status flip via the new
  `patchHistoryEntry`, which sets scalars without recreating item containers.
- `ConversationView` gains `retain()`, `planCount`, per-turn `fileDiff()`
  and an incremental `readAll()` that re-materializes only changed turns and
  keeps every other entry identity-stable, so identity-keyed render caches
  keep hitting through the bridge.
- Readers converted to index/tail reads: `use-session-doc` (revision compare,
  `updateHistoryEntry`, `useSessionDocSyncState`), `use-session-actions`
  (dispatch/steer user-turn reads), `use-task-actions`,
  `use-remove-local-project`, `managed-preview-surface`, the fork-origin
  observer in `session-detail`, `use-session-diff-summary` (per-turn
  `fileDiff` containers), `ai-gui/index.tsx` `lastUserMessageId`, and the
  active-assistant-turn / scroll-to-message reads in
  `session-chat-interface`. The rest of `session-chat-interface` stays on
  the bridge.
- `SessionDocument.getHistory()` on the CLI is documented as full
  materialization reserved for import hashing and dispatch scans.

Model: claude-fable-5-1
…use-session-doc

The Electron web tsconfig maps `@/lib`, `@/components` and `@/ui` into the components
package but not `@/providers`, so the alias broke `typecheck:web`; hooks reach providers
by relative path, as `atoms/runtime.ts` already does.

Model: claude-fable-5-1
…stive default

Type-aware lint (consistent-return) rejected the bare switch; the default is a
`never` guard so a new ContainerKind fails to compile instead of returning undefined.

Model: claude-fable-5-1
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