# Conversations Timeline Refactor — Audit & Plan Status: **draft — approved for implementation, not yet started** Scope: `app/src/pages/Conversations.tsx` and the conversation-timeline data layer (frontend + Rust turn-state persistence). Companion: [`per-turn-tool-timeline-history.md`](per-turn-tool-timeline-history.md) (adopted wholesale as the backend track). ## Context `app/src/pages/Conversations.tsx` (3,302 lines, one function component) is the app's reusable conversation panel — used by `Accounts.tsx` (page + sidebar variants) and `features/human/HumanPage.tsx` (sidebar + mic-cloud + projectThreadList). It is disorganized and buggy in four dimensions: **How it renders tool calls / thoughts / subagents** — five parallel state families composed ad hoc in one render function: durable messages (`threadSlice`, server order), a **single tool-timeline array per thread** (`chatRuntimeSlice.toolTimelineByThread`), a streaming-preview tail (`streamingAssistantByThread`, last 120 chars), parallel subagent streams (`parallelStreamsByThread`), and an inference-status line — each with its own conditional render gates (`hideAgentInsights` × `isSending` × anchoring produces 4+ fallback paths, L1775–1842). **How it orders** — no timestamps or sequence ids. Timeline entries carry only `round` + array insertion order. The single timeline is positionally **anchored after the last user message** (`Conversations.tsx:1756, 2533`, fallback `2669` for proactive threads). Each send **wipes** the timeline, so past turns permanently lose their process trail. **How it stores & streams** — `ChatRuntimeProvider.tsx` (1,664 lines) merges ~28 snake_case socket events (`tool_call`, `subagent_*`, `chat_interim`, `text_delta`, …) via `store.getState()` + find-row + **full-array-replace** in ~10 handlers. **Two parallel dedup mechanisms** (provider-local `seenChatEventsRef` string-key TTL map + reducer-level id dedup in `hydrateRuntimeFromRunLedger`) with **divergent row-id schemes** (live: `${thread}:subagent:${task}:${tool}` vs ledger: `subagent:${runId}`), plus provider-local `segmentDeliveriesRef` and the `preserveLiveSubagentProse` graft — a documented live-only-state workaround. **Tab-switch context loss** — the Rust store persists **one** turn snapshot per thread (whole-file overwrite, `src/openhuman/threads/turn_state/store.rs`), so thread/tab switch rehydrates only the latest turn; live subagent prose, past-turn trails, and the streaming tail are lost. **Reference patterns adopted** (from codex `codex-rs/protocol` + TUI research; hermes-agent confirmed as the minimal counter-example; vendored `tinychannels` is transport-only — its `ChannelOutputEvent` vocabulary and `RunLedger`/`ConversationStore` host traits stay compatible, the rich timeline model remains OpenHuman-owned): 1. **Two-layer model**: ephemeral streaming events (deltas, begin/end pairs keyed by `call_id`) vs **durable timeline items with stable ids**; deltas mutate the item with the matching id; persist only durable items; rehydrate by chronological replay. 2. **Typed item taxonomy**: one discriminated union, one React component per kind. 3. **Ordering**: stable per-item id + monotonic `seq`; per-turn grouping via `requestId`; subagents as flat items with `parentId`, nesting rendered not stored. --- ## Target architecture ### 1. Unified `TimelineItem` model — a selector projection, not a new slice `threadSlice` and `chatRuntimeSlice` remain sources of truth; a memoized selector (`selectTimelineForThread`) composes them into one ordered `TimelineItem[]`. This avoids a big-bang slice migration and lets each phase land independently. ```ts // app/src/features/conversations/timeline/types.ts interface TimelineItemBase { id: string; // stable: message id, or unified runtime row id (§3) turnId: string; // requestId; 'legacy' for pre-migration turns seq: number; // ordering within turn (reducer-assigned now, backend-stamped in Phase 4) threadId: string; } type TimelineItem = TimelineItemBase & ( | { kind: 'userMessage'; message: ThreadMessage } | { kind: 'assistantMessage'; message: ThreadMessage; interim: boolean } // chat_interim narration | { kind: 'streamingText'; text: string; streamId?: string } // ephemeral, live turn only | { kind: 'reasoning'; text: string; settled: boolean } | { kind: 'toolCall'; callId: string; name: string; status: 'running'|'ok'|'error'; args?: unknown; result?: unknown; round?: number } | { kind: 'subagentActivity'; taskId: string; parentCallId?: string; children: string[] } // flat store, rendered nested | { kind: 'approvalRequest' | 'plan' | 'workflowProposal'; /* wraps existing card payloads */ } ); ``` **Ordering**: turns order by first-message position in the thread; items within a turn by `seq`. Anchoring becomes structural — a turn group renders `[userMessage, ...processItems, ...assistantMessages]` — replacing the last-user-message positional hack. Messages without `requestId` fall into a single `legacy` turn that reproduces today's single-anchor behavior. A turn with no `userMessage` (proactive threads) is first-class: process items render before its first assistant message. ### 2. File layout — `app/src/features/conversations/` (matches `features/human/`; all files ≤ ~500 lines) ``` app/src/features/conversations/ ConversationsPage.tsx # page-shell variant ConversationsSidebar.tsx # sidebar variant (Accounts, HumanPage) index.ts # public exports = reusability contract timeline/ types.ts # TimelineItem union selectors.ts # projection + turn grouping (reselect-memoized) ConversationTimeline.tsx # pure renderer: TimelineItem[] → one component per kind items/ # UserMessageItem, AssistantMessageItem, ToolCallItem, # ReasoningItem, SubagentActivityItem, StreamingTailItem TurnInsightsGroup.tsx # collapsed-header/expanded-body per-turn wrapper (lazy-load) composer/ Composer.tsx / MicCloudComposer.tsx / useComposerState.ts / composerSendDecision.ts threadList/ ThreadList.tsx / threadFilter.ts components/ # existing subcomponents relocated as-is (ToolTimelineBlock, SubagentDrawer, TaskKanbanBoard, AgentProcessSourcePanel, …) app/src/pages/Conversations.tsx # thin re-export shim during migration; deleted in Phase 6 ``` **Reusability contract** (`index.ts`): ``, ``, and `` for future embedders. The `AgentChatPanel` export (Conversations.tsx:3302) is renamed to resolve the collision with the unrelated `components/settings/panels/AgentChatPanel.tsx`; `Accounts.tsx:25` updated. ### 3. Streaming/merge consolidation - All merge logic moves into `chatRuntimeSlice` **reducers** — one typed action per event family (`toolEventReceived`, `subagentEventReceived`, `textDeltaReceived`, `turnLifecycleReceived`). `ChatRuntimeProvider` shrinks to parse-and-dispatch (~300 lines); no `getState()`, no full-array rebuilds. - **One dedup mechanism**, reducer-level, on a unified row-id scheme: `${threadId}:${requestId}:${kind}:${callId|taskId|streamId}` — used identically by live handlers, `hydrateRuntimeFromSnapshot`, and `hydrateRuntimeFromRunLedger`. Kills the live-vs-ledger id divergence; `preserveLiveSubagentProse` becomes an ordinary upsert (merge rule: persisted fields win for settled rows, live fields win for streaming fields). - **Backend `seq` envelope** (Phase 4): `progress_bridge.rs` stamps a per-request monotonic `seq: u64` + `request_id` on every event; reducers dedup by `(requestId, seq)`. Reconnect-safe: replayed events with `seq <= lastSeq` are dropped; missed events recovered by re-hydration on reconnect (kept). Until then, the TTL-map dedup moves verbatim into the reducer as the interim mechanism. - `segmentDeliveriesRef` reconstruction becomes per-request segment state in the slice, cleared on `chat_done`. ### 4. Per-turn persistence (backend) Adopt `per-turn-tool-timeline-history.md` as written: per-turn files `turn_states//.json` with retention (N=20) + `latest` pointer, `turn_state_list` / `turn_state_get(requestId)` RPCs, idempotent legacy single-file migration, `extraMetadata.requestId` stamped on assistant messages (Option B anchoring). **Amendment**: persist `seq` on `PersistedToolTimelineEntry` (Rust `turn_state/types.rs` + `app/src/types/turnState.ts`) so replayed snapshots order identically to live streams. **Sequencing: frontend-first.** The projection, component split, and reducer consolidation all work against the current single-snapshot backend (everything lands in the `legacy`/live turn). The ring store then slots in underneath without touching the renderer. --- ## Phases (each a small PR, tests green throughout; branch off `upstream/main`, small focused commits) ### Phase 0 — Land this plan This document, committed to `docs/plans/`. ### Phase 1 — Mechanical extraction, no behavior change Create the `features/conversations/` skeleton; relocate `pages/conversations/{components,hooks,utils}`; extract ThreadList, Composer/MicCloudComposer/useComposerState, and the page/sidebar shells out of `Conversations.tsx`. `pages/Conversations.tsx` becomes a shim with the old props so `Accounts.tsx`/`HumanPage.tsx` are untouched. Rename `AgentChatPanel`. Shared thread/selection state via a `ConversationsContext` to avoid prop-drilling explosions. **Guardrails**: `Conversations.render.test.tsx` + 4 sibling tests, Accounts/HumanPage tests pass unmodified (import paths only). ### Phase 2 — `TimelineItem` projection + `` Add `timeline/types.ts`, `timeline/selectors.ts` (`legacy`-turn fallback ⇒ behaviorally identical to today's anchor logic), `ConversationTimeline.tsx` + `items/*` (wrapping existing `ToolTimelineBlock`, `AgentMessageBubble`, cards). Replace the render loop (~L2400–2700 of the old file). `hideAgentInsights` filters kinds in the selector, not components. **New tests**: `selectors.test.ts` (ordering, legacy fallback, empty thread, **proactive thread with no user message** — must reproduce the L2669 fallback), `ConversationTimeline.test.tsx` (snapshot per kind). ### Phase 3 — Reducer-side merge/dedup consolidation Typed actions in `chatRuntimeSlice`; move each provider handler's merge body into reducers; move `seenChatEventsRef` + `segmentDeliveriesRef` into slice state; unify row ids everywhere; replace `preserveLiveSubagentProse` with the field-level upsert. Verbose debug logging on every dedup drop / merge decision (repo convention). **Guardrails**: `ChatRuntimeProvider.test.tsx` (assertions rewritten to dispatched actions), `chatRuntimeSlice.test.ts`, `chatRuntimeSlice.toolFailure.test.ts`, `SubagentDrawer.test.tsx`. **New tests**: duplicate-event replay is a no-op; live subagent prose survives snapshot hydration; reconnect-replay simulation. ### Phase 4 — Backend per-turn ring store (companion-plan steps 1–3) Rust first (per AGENTS.md workflow Rust → RPC → UI): per-turn store layout + retention + legacy migration in `turn_state/store.rs`, mirror retention in `mirror.rs`, `seq` stamping in `progress_bridge.rs`, `seq` on `turn_state/types.rs`; `turn_state_list` / `turn_state_get(requestId)` RPCs; `extraMetadata.requestId` stamping (threadSlice `addInferenceResponse` + `chat_segment`/`chat_done`/`chat_interim` paths); wire types in `app/src/types/turnState.ts` + `threadApi.ts`. Invisible to UI. **Tests**: extend `store_tests.rs` (put/get/list, prune to N, latest pointer, idempotent legacy migration, `mark_all_interrupted` over dir), `mirror_tests.rs`; JSON-RPC E2E for the new methods. ### Phase 5 — Per-turn frontend (companion-plan step 4) `toolTimelineByThread` → `Record>` + `liveRequestIdByThread`; send no longer wipes history; dedup by `(requestId, seq)`, drop the interim TTL logic. Selector groups by real `requestId`; `TurnInsightsGroup` lazy-fetches `turn_state_get(requestId)` on first expand; thread-switch hydration = `turn_state_list` + latest snapshot only. **New tests**: multi-turn render (each turn keeps its trail), lazy-load, legacy-message fallback. ### Phase 6 — Cleanup Delete the `pages/Conversations.tsx` shim (point Accounts/HumanPage at `features/conversations`); delete dead state (`parallelStreamsByThread` if subsumed by `streamingText` items, `preserveLiveSubagentProse`, provider refs). i18n keys unchanged (all strings already via `useT`). --- ## Tab-switch context loss — cause → fix | Lost today | Cause | Fixed by | |---|---|---| | Past turns' process trails | single array wiped on send + single-snapshot store | Phases 4–5 | | Live subagent prose after rehydrate | hydration clobbers live-only rows (`preserveLiveSubagentProse` workaround) | Phase 3 (unified ids + field-level upsert) | | Streaming tail / interim segments | provider-local refs + latest-snapshot-only hydration | Phase 3 (segment state in slice) + Phase 4 | | Duplicate/ghost rows after reconnect | TTL dedup misses + id-scheme divergence | Phase 3 interim, Phase 4 `seq` definitive | ## Verification - Per phase: `pnpm test` (guardrail tests listed above), `pnpm typecheck`, `pnpm lint`; Phase 4 adds `cargo test` / `pnpm test:rust` + `tests/json_rpc_e2e.rs`; ≥80% diff coverage (CI Lite gate). - Manual QA after Phases 3 and 5 (`pnpm dev:app`): multi-turn thread with tools + subagents; switch tab mid-stream and back; kill/restore socket mid-turn; proactive thread with no user message; `hideAgentInsights` sidebar variant in HumanPage; app restart on a 5-turn thread — all trails restored, ordered identically to live. ## Risks - **Reconnect replay before `seq` exists** — keep the TTL-equivalent dedup in the reducer until Phase 4 lands; do not delete early. - **Proactive threads (no user message)** — first-class in the projection; tested in Phase 2. - **Snapshot migration** — legacy single-file read must be idempotent; keep the old read path one release. - **Selector performance on long threads** — reselect memoization per thread; collapsed turns hold metadata only (lazy-load bodies). - **`round` semantics** — `ToolTimelineBlock` coalescing keys off `round`; keep `round` alongside `seq`, don't replace it. ## Critical files - `app/src/pages/Conversations.tsx` (3,302 ln — split & shim) - `app/src/providers/ChatRuntimeProvider.tsx` (1,664 ln — shrink to dispatch) - `app/src/store/chatRuntimeSlice.ts` (1,698 ln — reducer-side merges, per-turn shape) - `app/src/store/threadSlice.ts` (`addInferenceResponse` requestId stamping) - `app/src/types/turnState.ts`, `app/src/services/api/threadApi.ts` (wire types/RPCs) - `src/openhuman/threads/turn_state/{store.rs,mirror.rs,types.rs}` (ring store, seq) - `src/openhuman/channels/providers/web/progress_bridge.rs` (seq envelope) - `docs/plans/per-turn-tool-timeline-history.md` (adopted backend design)