From 59ecd86b4b9b57a217f6a15a5cf501b90d205118 Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:43:16 +1100 Subject: [PATCH] perf: isolate chat streaming renders and reduce sidebar render cost (#1672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the chat and session-sidebar render paths to cut render cascades, memory churn, and UI jank on large sessions and big session trees. Behavior is preserved; the changes are about *when* and *how much* the UI re-renders. ## Chat streaming - Freeze the streaming message's parts in the bulk turn projection during streaming, and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream no longer re-runs the whole-session projection or re-renders unrelated rows. session with referential reuse of unchanged turns. - Memoize message rows with field-aware comparators instead of reference equality. - Replace the manual child-session polling in the task tool with the live SSE stream + a one-shot load, removing a fetch/settle state machine. ## History loading & scroll - Load an initial page fast, then prepend one older page in the background so the scroll container has headroom and "load older on scroll-up" fires before the user hits the absolute top. - Compensate scroll synchronously (in a layout effect, before paint) for prepends — including background prepends that don't originate from a user scroll — so the viewport stays stable instead of judder-correcting on the next frame. ## Markdown rendering - Render markdown synchronously *styled* on first paint (paragraphs, lists, code cards, tables, inline code) instead of raw escaped text; the async pass then only upgrades syntax-highlight colors. Eliminates the flash of full-width raw text. - Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown chunk, avoiding a late stylesheet injection on first render. ## Sidebar - Hoist per-row recursive tree walks out of row comparators into per-group precomputed sets/keys; batch live-session lookups into a single map; add a group-level memo boundary. - Isolate rename drafts so per-keystroke typing doesn't repaint the row tree. ## Sync layer - Add a staleness guard so a slow message fetch can't repopulate a session the user navigated away from. - Throw on fetch failure for authoritative loaders so a transient blip can't read as an empty server response. ## Cleanup - Remove dead code (unused hooks, params, duplicated inline types) surfaced while reworking the above. ## Known issue - A rare, purely cosmetic first-paint width flash can still appear on large sessions; it has no behavioral or data impact and is tracked for a follow-up runtime trace. --- CHAT_PERF_PLAN.md | 540 ---------------- RIGHT_SIDEBAR_PERF_PLAN.md | 53 -- .../ui/src/components/chat/ChatContainer.tsx | 84 +-- packages/ui/src/components/chat/ChatInput.tsx | 36 +- .../components/chat/MarkdownRendererImpl.tsx | 13 +- .../ui/src/components/chat/MessageList.tsx | 121 +--- .../chat/hooks/useChatTimelineController.ts | 69 +- .../components/chat/hooks/useTurnRecords.ts | 24 +- .../chat/lib/messageDisplayNormalization.ts | 79 +++ .../chat/lib/turns/projectTurnRecords.test.ts | 21 + .../chat/lib/turns/projectTurnRecords.ts | 69 +- .../chat/lib/turns/streamingTailEntry.test.ts | 134 ++++ .../chat/lib/turns/streamingTailEntry.ts | 86 +++ .../lib/turns/turnProjectionCache.test.ts | 35 + .../chat/lib/turns/turnProjectionCache.ts | 86 +++ .../src/components/chat/markdown/decorate.ts | 6 +- .../components/chat/markdown/markdownCore.ts | 26 +- .../chat/message/parts/ToolPart.tsx | 596 +++++------------- .../resolveFallbackTaskSessionId.test.js | 9 +- .../parts/resolveFallbackTaskSessionId.ts | 42 +- .../chat/revertedMessageDockState.test.ts | 89 +++ .../chat/revertedMessageDockState.ts | 73 +++ .../components/session/SessionFolderItem.tsx | 14 +- .../src/components/session/SessionSidebar.tsx | 391 +++++------- .../session/sidebar/SessionGroupSection.tsx | 455 +++++++++++-- .../session/sidebar/SessionNodeItem.tsx | 452 ++++++++----- .../sidebar/SidebarActivitySections.tsx | 70 +- .../session/sidebar/SidebarProjectsList.tsx | 53 +- .../sidebar/hooks/useProjectRepoStatus.ts | 39 +- .../sidebar/hooks/useProjectSessionLists.ts | 45 +- .../hooks/useProjectSessionSelection.ts | 23 +- .../sidebar/hooks/useSessionActions.ts | 4 +- .../sidebar/hooks/useSidebarBulkActions.ts | 237 +++++++ .../session/sidebar/sessionNodeItemUtils.ts | 120 ++++ .../ui/src/components/ui/ScrollShadow.tsx | 3 +- packages/ui/src/index.css | 6 + .../ui/src/stores/useGlobalSessionsStore.ts | 32 + packages/ui/src/styles/typography.css | 15 + .../src/sync/scoped-blocking-requests.test.ts | 54 ++ .../ui/src/sync/scoped-blocking-requests.ts | 61 ++ packages/ui/src/sync/session-actions.ts | 75 ++- .../src/sync/session-message-records.test.ts | 68 ++ packages/ui/src/sync/session-ui-store.ts | 8 + packages/ui/src/sync/sync-context.tsx | 223 +++++-- packages/ui/src/sync/use-sync.ts | 62 +- .../ui/src/sync/user-message-history.test.ts | 98 +++ packages/ui/src/sync/user-message-history.ts | 98 +++ 47 files changed, 3168 insertions(+), 1829 deletions(-) delete mode 100644 CHAT_PERF_PLAN.md delete mode 100644 RIGHT_SIDEBAR_PERF_PLAN.md create mode 100644 packages/ui/src/components/chat/lib/messageDisplayNormalization.ts create mode 100644 packages/ui/src/components/chat/lib/turns/streamingTailEntry.test.ts create mode 100644 packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts create mode 100644 packages/ui/src/components/chat/lib/turns/turnProjectionCache.test.ts create mode 100644 packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts create mode 100644 packages/ui/src/components/chat/revertedMessageDockState.test.ts create mode 100644 packages/ui/src/components/chat/revertedMessageDockState.ts create mode 100644 packages/ui/src/components/session/sidebar/hooks/useSidebarBulkActions.ts create mode 100644 packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts create mode 100644 packages/ui/src/sync/scoped-blocking-requests.test.ts create mode 100644 packages/ui/src/sync/scoped-blocking-requests.ts create mode 100644 packages/ui/src/sync/session-message-records.test.ts create mode 100644 packages/ui/src/sync/user-message-history.test.ts create mode 100644 packages/ui/src/sync/user-message-history.ts diff --git a/CHAT_PERF_PLAN.md b/CHAT_PERF_PLAN.md deleted file mode 100644 index 75e4980a..00000000 --- a/CHAT_PERF_PLAN.md +++ /dev/null @@ -1,540 +0,0 @@ -# Chat Session Performance Plan - -## Goal - -Reduce render cost and history-load latency when switching between chat sessions -in `packages/ui/src/components/chat/`, especially on Windows desktop with -projects that have a long-running chat history (50–500 messages per session, -tool-heavy workloads where each tool call returns 10–200 KB of output). - -The plan is **strictly behavior-preserving**: every change keeps the visible -behavior 1:1 with the current build. Differences are only in the cost of doing -the work, not in the UX, layout, limits, persistence, or order of operations. - -The two scenarios this targets: - -- Switching between sessions in the sidebar (especially in/out of a session - with a long history) — the main window currently blocks the main thread - for 200–500 ms on first paint while `JSON.parse` + `materializeSessionSnapshots` - + `projectTurnRecords` run synchronously over the full initial page. -- Returning to a session you visited a few minutes ago — the in-memory LRU - (`SESSION_MESSAGE_RECORDS_CACHE_MAX = 40` desktop) helps, but the second - visit to a session outside that window still pays the full network + parse - cost. - -A secondary goal is to avoid hammering the OpenCode server with large -single-shot fetches; we want smaller, more frequent, deduped requests. - -## Upstream context - -Before implementing, I scanned open/closed PRs for overlap and lessons: - -- **#1651 — "perf: migrate chat rendering to virtua"** (`b920fd6f`, MERGED). - Already moved chat history to `virtua` virtualization with `bufferSize: 900` - and `MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5`, plus deferred Prism highlight, - mermaid/file annotation, table listeners, stable plugin lists. This plan - builds on top of that. -- **#1650 — "perf: instant startup via cache hydration + decoupled readiness"** - (`c62f0d1c`, MERGED). Instant startup + decoupled readiness — orthogonal. -- **#1503 — "fix(chat): do not force scroll in thinking block during streaming"** - (OPEN). Scroll behaviour during streaming — not in our hot path. -- **#1584 — "feat: improve long user message collapse, scrolling, and navigation"** - (DRAFT). Touches long user messages; no direct conflict with this plan. -- **#1621 — "feat: comprehensive scrolling and navigation improvements"** - (DRAFT). Broad scroll/navigation PR; no direct conflict. -- **#1282 — "Optimize large-session sidebar trees, switching UX, and chat - scroll restore stability"** (CLOSED by maintainer). Introduced session- - switching instability, selection/chat desync, optimistic transitions. - Lesson: keep this plan focused on render-cost reduction and stable memoization; - do NOT add optimistic transitions, backend search endpoints, scroll- - restoration loops, or pagination on top of an unstable base. -- **#1448 — "feat: implement unified multi-server sidebar"** (DRAFT). Rewrites - large parts of `SessionSidebar`. Independent of this plan. - -There is currently NO open PR targeting the same render-cost or history-load -problems for the chat panel. We can proceed without coordination. - -## Current Architecture Notes - -### Data flow on session switch - -1. `ChatContainer` subscribes to `currentSessionId` from `useSessionUIStore`. -2. `useSessionMessageRecords(currentSessionId, directory)` reads from the - directory-scoped sync store via `React.useSyncExternalStore`. Snapshot - cache: `SESSION_MESSAGE_RECORDS_CACHE_MAX = 40` desktop, `4` vscode/mobile. -3. `useChatTimelineController.turnWindowModel` is rebuilt via - `buildTurnWindowModel(messages)` on first open of a session; cached per - sessionId in module-level `turnModelCache` (max 30 desktop, 4 constrained). -4. `MessageList.baseDisplayMessages` reverses + dedupes + normalizes each - message via `getNormalizedMessageForDisplay` (per-message `WeakMap` cache). -5. `useTurnRecords` projects turn records via `projectTurnRecords` (O(N) over - messages + per-turn work for summary/activity/diffStats/changedFiles). - `previousProjectionRef` stabilizes across re-renders, but there is no - module-level projection cache — returning to a session after the component - unmounts pays the full projection cost again. -6. `useChatTimelineController` runs the `useLayoutEffect` on `[sessionId]` - that resets `isLoadingOlder`, `pendingRevealWork`, `activeTurnId`. - -### Render hot path (current cost map) - -- `ChatContainer` (990 lines) subscribes to the full directory `permission` - and `question` records via `useDirectorySync((s) => s.permission ?? {})` and - `useDirectorySync((s) => s.question ?? {})` (`ChatContainer.tsx:436-443`). - Any `permission.asked` or `question.asked` event for any session in the - same directory re-renders `ChatContainer` and re-runs the `permissionsMap` / - `questionsMap` / `scopedSessionIds` / `sessionPermissions` / `sessionQuestions` - / `sessionIsWorking` chain. Same for `useSessions(directory)` — every - `session.updated` event for any session in the directory re-renders the - container. -- `MessageList.reviewTransferDirection` (`MessageList.tsx:1151`) does - `useGlobalSessionsStore((state) => { const currentSession = state.activeSessions.find(...) })` - — runs `find` + `some` over the entire global active list on every global - store change. -- `MessageList` already has a `useStableEvent(getAnimationHandlers)`, - `useStableEvent(scrollToBottom)`, and a module-level `timelineCache` for - the virtua cache snapshot — those are the right pattern, just not applied - uniformly. -- `ChatMessage` (1182 lines) is `React.memo`-wrapped with - `areRenderRelevantMessagesEqual` plus `areRelevantTurnGroupingContextsEqual`. - `MessageBody` (2121 lines) is NOT `React.memo`-wrapped but is only - rendered through `MessageRow` → `ChatMessage` and only re-runs when - `areRenderRelevantMessagesEqual` returns false. - -### The 1.5 MB problem - -The OpenCode server (separate repo, `anomalyco/opencode`) responds to -`GET /api/session/{sessionID}/message` with -`Array<{ info: Message, parts: Part[] }>` (the v1 wire shape preserved in -the v2 SDK). On a typical tool-heavy session of 150 messages, this array -serializes to ~1.5 MB of JSON. The bulk is `ToolStateCompleted.output: string` -(confirmed in `node_modules/.bun/@opencode-ai+sdk@1.17.7/.../types.gen.d.ts:357-373`). - -The OpenChamber web server (`packages/web/server/index.js:1115`) installs -Express `compression()` middleware BEFORE the OpenCode proxy -(`packages/web/server/lib/opencode/proxy.js`), so the response IS compressed -on the way out (5x reduction on typical JSON, so ~300 KB on the wire). The -proxy explicitly sets `accept-encoding: identity` (`proxy.js:629`) which is -a no-op against the upstream OpenCode server (it has no compression -middleware — `server.ts` uses raw `@effect/platform-node` NodeHttpServer). - -So the **wire cost is ~300 KB after gzip**, not 1.5 MB. The real cost is the -**client-side main-thread blocking**: - -- `JSON.parse` on 1.5 MB of structured data: ~50–200 ms in V8. -- `materializeSessionSnapshots`: O(N) sort/filter, ~10–20 ms for 150. -- `projectTurnRecords`: O(N) with per-turn work (summary/activity/diffStats/ - changedFiles/indexes), ~30–100 ms for 150. -- `useChatTimelineController.useLayoutEffect` re-resolves pending scroll - requests and updates `historySignals` — fast but runs synchronously. - -Total main-thread block on first paint: **200–500 ms**. The user-perceived -"long load" is mostly this. - -### Limit constants (current) - -| Constant | Value | Location | Purpose | -| ------------------------------------- | --------------------------------- | --------------------- | --------------------------------------- | -| `INITIAL_MESSAGE_PAGE_SIZE` | 150 (desktop), 30 (vscode/mobile) | `use-sync.ts:25-27` | First page on session switch | -| `HISTORY_MESSAGE_PAGE_SIZE` | 200 | `use-sync.ts:28` | Scroll-up pagination | -| `MESSAGE_REFETCH_LIMIT` | 200 | `session-actions.ts:27` | Refetch after revert/abort | -| `RECONNECT_MESSAGE_LIMIT` | 30 | `sync-context.tsx:193` | Reconnect bootstrap | -| `SESSION_MATERIALIZATION_MESSAGE_LIMIT` | 30 | `sync-context.tsx:194` | Materialize on `message.updated` recovery | -| `DEFAULT_MESSAGE_LIMIT` | 200 | `sessionTypes.ts:78` | Declarative ceiling; not enforced | - -For 99% of sessions, users read the latest 30–50 messages and rarely scroll -beyond. The 150-initial / 200-history combination fetches ~3x more than the -visible window on first paint. - -### Already optimized (do not touch) - -- `MessageList` virtualization with `virtua` at threshold 5, bufferSize 900 - (PR #1651). -- `MessageRow` / `ChatMessage` / `TurnBlock` `React.memo` with custom - comparators. -- `getNormalizedMessageForDisplay` per-message `WeakMap` cache. -- `MarkdownRenderer` lazy-loaded via `lazyWithChunkRecovery`; Prism highlight - and mermaid deferred (PR #1651). -- `expandedToolsStateCache` and `collapsedToolsStateCache` module-level LRU - bounded at 4000 (`ChatMessage.tsx:39-95`). -- `aggregateLiveSessions` / `aggregateLiveSessionStatuses` bail via - `areSessionListsEquivalent` and `areStatusMapsEquivalent`; consumed through - `useLiveSyncSelector`. -- `useStickyProjectHeaders` IntersectionObserver. -- Targeted field cloning in `handleDirectoryEvent` (sync/DOCUMENTATION.md). -- `turnModelCache` for `buildTurnWindowModel` (30 desktop, 4 vscode/mobile). -- `timelineCache` for virtua snapshot (max 16 sessions). -- `session-prefetch-cache` 15s TTL dedup of `syncSession` requests. -- `syncSessionInflightByKey` for in-flight dedup. - -## Proposed Architecture - -### Bugfix Layer 0 — Fix rapid project-switch race in sidebar - -**Observed bug:** When connected from desktop to an OpenChamber server, with -multiple trees in the sidebar and large sessions (~200k context), rapidly -switching between recent sessions in different projects causes the focus to -follow the **fetch completion order** instead of the user's final click. The -sequence is: user clicks 1, then 2, then 3; UI briefly shows 3; when fetch 1 -finishes it jumps to 1; when fetch 2 finishes it jumps to 2; when fetch 3 -finishes it finally lands on 3. - -**Root cause:** `useProjectSessionSelection` (`useProjectSessionSelection.ts`) -has auto-selection logic that runs when `activeProjectId` changes. Combined -with uncancelled async fetches and cross-project store updates, stale fetch -completions update the sidebar session lists, which re-triggers selection -fallbacks and overwrites the user's explicit choice. - -**Fix — four parts:** - -1. **Layer 0a — Generation guard in `useEnsureSessionMessages`.** - Track a per-hook generation counter. When the effect fires for a new - `sessionID`, increment the generation. Ignore the result (do not apply - store mutations) if the generation is stale by the time the async fetch - completes. - -2. **Layer 0b — Generation guard in `syncSession` / `loadMessages`.** - Add a module-level `Map` tracking the latest requested - generation per session. Before every store write inside `syncSession` and - `loadMessages`, check that the request is still current. This prevents - fetches 1 and 2 from mutating state after the user has already selected 3. - -3. **Layer 0c — Debounce `handleSessionSelect`.** - In `useSessionActions.ts`, wrap the actual navigation in a short timeout - (~80ms). The first click executes immediately; rapid subsequent clicks - cancel the pending navigation and reschedule for the latest target. This - coalesces 1→2→3 into a single `setCurrentSession(3)` call. - -4. **Layer 0d — Explicit-selection guard in `useProjectSessionSelection`.** - Track the timestamp of the last explicit user selection. Suppress the - auto-select fallback in the layout effect for ~500ms after an explicit - click, so fallback logic never overwrites an active user choice. - -**Effect:** Rapid project switching becomes deterministic: the UI stays on the -last clicked session and only loads its content. Stale fetches are silently -ignored instead of fighting for focus. - -**Files:** `sync-context.tsx`, `use-sync.ts`, `useSessionActions.ts`, -`useProjectSessionSelection.ts`. - ---- - -### Layer 1 — Narrow subscriptions in ChatContainer - -**Problem:** `ChatContainer.tsx:436-443` subscribes to the **full directory** -`permission` and `question` records. Any permission/question event for **any** -session in the directory causes a ChatContainer re-render, even if the event -is for an unrelated session. Same for `useSessions(directory)` at line 427 -— every `session.updated` event re-renders the container. - -There are already session-scoped hooks (`useSessionPermissions(sessionID)`, -`useSessionQuestions(sessionID)` at `sync-context.tsx:2151,2165`), but they -don't aggregate across descendant subagent sessions — which -`collectVisibleSessionIdsForBlockingRequests` (`ChatContainer.tsx:458-464`) -handles for the `sessionPermissions` / `sessionQuestions` / `sessionIsWorking` -chain. - -**Fix:** Replace the broad directory reads with scoped subtree selectors. The -subtree is the current session and all its descendant subagent sessions. - -Add to `sync-context.tsx`: - -- `useScopedSubtreeIds(sessionID: string | null, directory?: string): Set` - — returns the set of session IDs in the current subtree (current session + - all descendants by `parentID`). Reference-stabilized: a new Set is only - returned when the actual membership changes, using the same "compare - contents, reuse reference" pattern as `areSessionListsEquivalent`. - -- `useScopedBlockingRequests(sessionID: string | null, directory: string, kind: 'permission' | 'question')` - — combines `useScopedSubtreeIds` with the existing `useSessionPermissions` / - `useSessionQuestions` patterns to return a flat array of blocking requests - scoped to the subtree only. - -- `useParentSession(sessionID: string | null): Session | null` — returns - the parent Session via a pre-computed `Map` that only - changes when the target session or its parent changes. Replaces the O(N) - `.find()` at `ChatContainer.tsx:564`. - -`ChatContainer` then: -- reads `useScopedBlockingRequests` instead of the full `allPermissions`/ - `allQuestions` → `permissionsMap` → `scopedSessionIds` → `sessionPermissions` - / `sessionQuestions` chain. -- reads `useParentSession` instead of `sessions.find(...)`. -- stops subscribing to `useSessions(directory)` entirely (the subtree selectors - provide all needed session data scoped to the active subtree). - -**Effect:** Third-party sessions in the same directory stop re-rendering -ChatContainer. Streaming events for unrelated sessions no longer invalidate -the chat's blocking-requests chain. - -**Files:** `ChatContainer.tsx`, `sync-context.tsx`. - ---- - -### Layer 3 — Module-level projection cache - -**Problem:** `useTurnRecords` keeps `previousProjectionRef` for in-component -stabilization, but a session that unmounts and remounts loses that ref. Full -re-projection cost is paid every time you return to a session. - -**Fix:** Add a module-level LRU keyed by `sessionKey`. The cache key must -capture the exact identity of the message list to avoid stale projections: - -```ts -// Includes: sessionKey, message count, last message id, -// AND the count of parts on the last message (detects streaming deltas -// that add parts to an existing message without changing id or count). -type CacheKey = `${sessionKey}|${length}|${lastMessageId ?? ''}|${lastMessagePartCount ?? 0}` -const projectionCache = new Map() -``` - -Including `lastMessagePartCount` is critical: during streaming, new parts are -appended to the same last message — the message ID and total message count -stay the same, but the projection must be recomputed. - -Cap at the same limits as `turnModelCache` (30 desktop, 4 vscode/mobile) -to bound memory. - -`useTurnRecords` consults the cache before running `projectTurnRecords`; on -hit, returns the cached projection and refreshes LRU order; on miss, runs the -full projection and writes the result. - -**Effect:** Returning to a session within the LRU window is zero projection -work. Combined with `MessageList.timelineCache` (which already caches the -virtua snapshot), the first paint after a remount is essentially "rebuild -only what's strictly session-specific (resolvers, refs)". - -**Files:** `useTurnRecords.ts` (small extension) + new helper -`lib/turns/turnProjectionCache.ts`. - ---- - -### Layer 4a — Start message fetch synchronously in setCurrentSession - -**Problem:** `session-ui-store.setCurrentSession` currently only calls -`setState` (Zustand). The actual message fetch is started by -`ChatContainer.useEffect` at `ChatContainer.tsx:787-792`, which runs on the -render AFTER the state update — adding one React commit cycle (~30–80 ms) -of latency before the network request starts. - -**Fix:** Start the fetch directly from `setCurrentSession` on the same tick -as the state update, using the existing imperative access layer. - -The approach uses `session-actions.ts`, which already has module-level refs -(`_sdk`, `_childStores`, `_getDirectory` — `session-actions.ts:33-35`) set by -SyncProvider at mount time. Add a `fetchMessagesForSession(sessionID: string)` -function there that duplicates the core path from `useSync().syncSession`: -checks the child store for existing messages, fetches from SDK if needed, -calls `materializeSessionSnapshots`, and writes to the directory store. - -`setCurrentSession` then calls `fetchMessagesForSession(targetId)` immediately -after `set({ currentSessionId: id, ... })`. The `syncSessionInflightByKey` -in `use-sync.ts` doesn't cover this new path, so the function also uses the -existing `_ensureMessagesLoading` Set in `sync-context.tsx` (line 2668) for -dedup. - -The existing `ChatContainer.useEffect` at line 787 stays as a safety net for -sessions restored from URL or other non-sidebar entry points. - -**Effect:** Fetch starts on the same tick as the state update, ~30–80 ms -earlier than the current "wait for React commit → useEffect → fetch" path. - -**Files:** `session-ui-store.ts` (one new call in `setCurrentSession`), -`session-actions.ts` (new `fetchMessagesForSession` function). - ---- - -### Layer 5 — Small wins - -- **`reviewTransferDirection` pre-computed map.** In - `MessageList.tsx:1151-1164`, replace - `useGlobalSessionsStore((state) => state.activeSessions.find(...))` with - a selector that reads a pre-computed `Map` - maintained by `useGlobalSessionsStore` (built once per active-sessions - change, in the same reducer that updates `activeSessions`). Same selector - shape from the component's perspective, but no `.find()` + `.some()` on - every global store change. - -- **`parentSession` lookup.** Part of Layer 1 (see `useParentSession` hook - above). The pre-computed `Map` is maintained in - `sync-context.tsx` and read via the hook, eliminating the O(N) `.find()` - in `ChatContainer`. - -**Files:** `MessageList.tsx`, `useGlobalSessionsStore.ts`. - ---- - -### Layer 6a — Reduce initial payload - -Lower the page sizes to match typical user reading patterns: - -- `INITIAL_MESSAGE_PAGE_SIZE`: 150 → 50 (desktop), 30 stays (vscode/mobile). -- `HISTORY_MESSAGE_PAGE_SIZE`: 200 → 100. -- `MESSAGE_REFETCH_LIMIT`: 200 → 100. -- `DEFAULT_MESSAGE_LIMIT` in `sessionTypes.ts`: stays 200 (it's a ceiling, - not a fetch target). - -**How the "load more" indicator still works with a smaller initial page:** -`historyMeta` in ChatContainer is built from `sync.hasMore(currentSessionId)`, -which reads `meta.current` (set immediately after the first `loadMessages` -call, before the component renders). If the server returns a cursor, -`sync.hasMore()` returns `true` → `historyMeta.complete = false` → -`hasMoreAboveTurns = true` → scroll-up indicator appears correctly. -The `messages.length >= defaultLimit` fallback is never reached in practice -because `historyMeta` is populated before the first render of the chat. - -Effect on the 1.5 MB problem: - -- Initial fetch payload: 1.5 MB → ~500 KB parsed (50 messages). -- JSON.parse cost: 100–200 ms → 30–70 ms. -- projection cost: 30–100 ms → 10–30 ms. -- Combined main-thread block: 200–500 ms → 50–150 ms. - -User cost: one extra round trip when scrolling past the 50th message. The -scroll-triggered `loadMore` is already in place and is the right UX for "I -want to see older messages" — the previous behavior of "fetch 150 and ignore -100" was wasted work for the common case. - -**Files:** `use-sync.ts`, `session-actions.ts`. - ---- - -### Layer 6b — Progressive mount - -`use-sync.syncSession` starts the initial 50-message page synchronously. -After the first page resolves, if the cursor indicates more messages, a -second fetch of `HISTORY_MESSAGE_PAGE_SIZE` (100 messages) is dispatched -via `loadMessages(sessionID, { before: cursor, mode: "prepend" })` — the -prepend mode already exists in `loadMessages` (`use-sync.ts:373`). This -second page is non-blocking; the user sees the first 50 messages immediately. - -On the client side, `useTurnRecords` and `useChatTimelineController` must -re-run projection when the prepended messages arrive. Two options: - -- **Simple:** Let the full re-projection run on the second page arrival - (50→150 messages). With Layer 6a, projection of 150 messages is ~10–30 ms — - fast enough that a one-time re-project on the second page is acceptable. - -- **Optimal:** Extend `updateTurnWindowModelIncremental` (currently handles - only +1 message appends — `windowTurns.ts:76-84` checks - `nextMessages.length !== previousMessages.length + 1`) to handle batch - prepends. This requires: - 1. A new `updateTurnWindowModelBatchPrepend` that verifies the new messages - are all prepended (all existing message references match at the tail) - and projects only the new messages into turn windows. - 2. A corresponding `updateTurnProjectionIncremental` in `projectTurnRecords.ts` - that merges the new turns into the existing projection. - - Because `updateTurnWindowModelIncremental` is designed for single-message - streaming deltas (not batch prepends), this is non-trivial additional work. - Given Layer 6a alone reduces the main-thread block to ~50–150 ms, start - with the simple approach and measure before committing to the incremental - path. - -**Net effect:** The first 50 messages mount within the same time as today -(possibly faster, because parsing 500 KB < parsing 1.5 MB). The next 100 -arrive ~200–400 ms later. From the user's perspective, the chat becomes -interactive immediately. - -**Files:** `use-sync.ts`, `ChatContainer.tsx` (small — no change to `loadMore` UX). - ---- - -## What I do not propose to touch (and why) - -- `useChatAutoFollow` and the scroll-restoration logic in - `useChatTimelineController` (the `prePrependScrollRef` / height-delta - compensation). PR #1282 closed because of scroll-restore regressions; - these are working and tested. -- The event-pipeline coalescing and `message.part.delta` reducer path. - Already correct (sync/DOCUMENTATION.md). -- The Markdown renderer and Prism highlight deferral. PR #1651 already took - the low-hanging fruit. -- The `key={currentSessionId}` on ``. Forces a full remount, - which is expensive, but a previous PR attempt (Layer 2 in the sidebar - plan) regressed scroll/follow. Keeping the remount for now. -- The OpenCode server response shape. It's in `anomalyco/opencode`, which - AGENTS.md forbids us from touching. -- The SDK's internal `JSON.parse` step. Replacing it with a streaming - parser requires owning the fetch call (the SDK doesn't expose the - `ReadableStream`), and the response is a single JSON array, not NDJSON — - so streaming buys us the same "first N visible, rest in background" - pattern that Layer 6b already implements. -- Tool output lazy-load. Would need a server-side endpoint like - `GET /api/session/{sid}/part/{partID}` that the OpenCode server does - not currently provide. - -## Expected effect - -- **Initial session switch on a 150-message tool-heavy session:** - main-thread block 200–500 ms → 50–150 ms. ~3x faster. -- **Wire payload on the same session:** 1.5 MB parsed → 500 KB parsed. - After gzip, ~300 KB → ~100 KB. -- **Returning to a session within the LRU window:** zero projection work - (Layer 3), zero scroll-cache rebuild (existing `timelineCache`). -- **Returning to a session outside the LRU window:** same as initial, but - the 15s `session-prefetch-cache` TTL catches sessions visited twice in - rapid succession. -- **Server load:** the OpenCode server processes 1/3 the volume per - session-switch, and the warm second page in Layer 6b is deduped - against the inflight request. Strictly less load than today. - -## Files Touched by the Plan - -- `CHAT_PERF_PLAN.md` (new, this file) -- `packages/ui/src/sync/sync-context.tsx` (Bugfix Layer 0a — `useEnsureSessionMessages` guard) -- `packages/ui/src/sync/use-sync.ts` (Bugfix Layer 0b — `syncSession`/`loadMessages` guard; Layer 6a — constants) -- `packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts` (Bugfix Layer 0c — debounce) -- `packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts` (Bugfix Layer 0d — explicit-selection guard) -- `packages/ui/src/components/chat/ChatContainer.tsx` (Layers 1, 6b) -- `packages/ui/src/components/chat/MessageList.tsx` (Layer 5a) -- `packages/ui/src/stores/useGlobalSessionsStore.ts` (Layer 5a — pre-computed map) -- `packages/ui/src/sync/sync-context.tsx` (Layer 1 — new hooks: `useScopedSubtreeIds`, `useScopedBlockingRequests`, `useParentSession`) -- `packages/ui/src/sync/session-ui-store.ts` (Layer 4a — call fetch from `setCurrentSession`) -- `packages/ui/src/sync/session-actions.ts` (Layer 4a — new `fetchMessagesForSession`, Layer 6a — constants) -- `packages/ui/src/sync/use-sync.ts` (Layer 6a — constants, Layer 6b — progressive fetch) -- `packages/ui/src/stores/types/sessionTypes.ts` (Layer 6a — sync `DEFAULT_MESSAGE_LIMIT` with new constants) -- `packages/ui/src/components/chat/hooks/useTurnRecords.ts` (Layer 3 — projection cache) -- `packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts` (new, Layer 3) - -## Implementation order - -1. **Bugfix Layer 0** (race condition fixes) — highest priority, fixes the - observed sidebar switching bug before any optimization work. -2. **Layer 6a** (reduce constants) — simplest performance win, also reduces - the race window. -3. **Layer 3** (projection cache) — isolated change, immediate return-visit win. -4. **Layer 5a** (reviewTransfer map) — isolated, low risk. -5. **Layer 1** (narrow subscriptions) — more complex but well-bounded. -6. **Layer 4a** (early fetch) — depends on Layer 1 for dedup safety. -7. **Layer 6b** (progressive mount) — highest complexity, implement after - measuring real-world gains from 6a. - -## Conflict risks - -- None of the open PRs touch the files above in a conflicting way. PR #1584 - (draft, long user message collapse) modifies `UserTextPart.tsx`, not the - files we touch. PR #1621 (draft, comprehensive scrolling) modifies scroll - and navigation components, not the chat data flow. PR #1448 (draft, - unified multi-server sidebar) is for `SessionSidebar.tsx` and family, not - the chat panel. -- Existing internal benchmarks for `event-pipeline` are unaffected - (Layers 1, 3, 4a, 5, 6 do not touch event coalescing or ordering). -- `bun run type-check` and `bun run lint` must remain green. - -## Open Questions - -1. **Layer 2 (drop `key={currentSessionId}` from `ChatViewport`):** explicitly - out of scope per user. Revisit after the lower-risk layers ship. -2. **Layer 7 (Web Worker for `JSON.parse` + materialize):** out of scope. - The Layer 6a/6b combo should bring main-thread block to ~50–150 ms, - which is acceptable. Revisit if real-user feedback still shows jank. -3. **Layer 6b incremental projection for batch prepends:** start with the - simple full-reproject-on-second-page approach (50→150 projection is - ~10–30 ms post-Layer-6a). Only build the incremental path if benchmarks - show the full reproject is still noticeable. -4. **Layer 3 cache invalidation during streaming:** the `lastMessagePartCount` - in the cache key catches part deltas on the last message. If the streaming - model appends parts to a message that is NOT the last (e.g., tool output - on a completed turn while a new turn is streaming), the cache would miss — - which is correct (projection must re-run). Confirm this edge case during - smoke testing. diff --git a/RIGHT_SIDEBAR_PERF_PLAN.md b/RIGHT_SIDEBAR_PERF_PLAN.md deleted file mode 100644 index c7a37c76..00000000 --- a/RIGHT_SIDEBAR_PERF_PLAN.md +++ /dev/null @@ -1,53 +0,0 @@ -# Right Sidebar Performance Plan - -## Status — DONE (PR #1674) - -### P0 — Correctness / leak fixes - -- [x] **RightSidebar**: drop dead `useEffect` that re-nulled refs the resize handler already nulled; collapse redundant `width`/`minWidth`/`maxWidth` triple into `width` + the existing `--oc-right-sidebar-width` variable. -- [x] **useUIStore**: clamp `setRightSidebarWidth` to `[MIN, MAX]`; simplify `setRightSidebarOpen` (22 lines → 12). -- [x] **RightSidebarTabs / `useRightSidebarGitSync`**: takes right tab + main tab; only polls when the right git tab is the visible consumer AND the browser is online + visible. Replaces a poll that ran for the lifetime of any open sidebar. -- [x] **GitView**: commit-files fetch refactored to `cancelled` + `Promise.all` (was a per-hash loop that could `setState` after unmount); `getRemoteUrl` and `refreshRemotes` gated on `cancelled` / `mountedRef`; new module-scoped `mountedRef` guards `setIsSettingIdentity` from firing after unmount. -- [x] **GitView + `useGitmojiList`**: extract gitmoji fetch/cache into a hook with module-level inflight promise + subscribers Set; stale-while-revalidate from localStorage; `ensureLoaded()` for call-site-initiated hydration; `cancelled` flag on `setIsLoading` to avoid the React setState-after-unmount race. -- [x] **ProjectNotesTodoPanel**: 400 ms notes debounce now cancels on blur (was double-saving); `persistProjectData` chained per project through a module-level `Map` so a fast todo toggle racing the debounced save no longer hits the server in parallel; resize auto-adjust guards against same-value pings. - -### P1 — Render fanout - -- [x] **RightSidebarTabs**: all three tab content components now always mounted with the `hidden` attribute. State and cache survive tab switches. When `activeMainTab === 'git'` (or `'context'`) the matching right tab is filtered out of the tab strip and a redirect effect snaps any persisted-but-now-hidden right tab to `'files'`. `onSelect` is now a type-guarded handler instead of `as RightTab`. -- [x] **GitView**: 13 separate `useGitStore` action selectors collapsed into one `useShallow` block (one re-evaluation per store change instead of 13). -- [x] **GitView**: new `isGitViewActive` flag (true when this instance is the visible consumer) gates the 7 live effects — load identities, fetch remote URL, refresh remotes, `ensureAll`, `sessionEvents.onGitRefreshHint`, worktree bootstrap poll, default-identity auto-apply. Hidden GitView instances no longer run these. -- [x] **GitView**: `gitViewSnapshots` module-level Map is now backed by an LRU wrapper (cap 20) so per-directory draft snapshots cannot leak across hundreds of project switches. Removed the dead `unique.set` dedup in `changeEntries` — `GitStatus.files` is already unique by path. -- [x] **SidebarFilesTree**: `statusByPath` `Map` and `badgeByDir` `Map` are precomputed once per `gitStatus` change. Tree render is O(1) per node instead of O(N) per node. `badgeByDir` walks each file's path segments and increments counters for every ancestor dir. -- [x] **SidebarFilesTree**: `FileRow` wrapped in `React.memo` with a custom comparator. Context-menu open state moved INTO `FileRow` as local state — opening a menu in one row no longer re-renders siblings. -- [x] **SidebarFilesTree**: `loadDirectory` accepts an `isCancelled` predicate; the batch-load effect for `expandedPaths` passes a stable predicate so per-dir fetches stop touching state once the effect tears down. -- [x] **SidebarFilesTree**: module-level `fileTreeCacheByRoot` Map (LRU, cap 8 roots) hydrates `childrenByDir` / `loadErrorsByDir` / `loadedDirsRef` on mount or root change. Mirror effects write state back to the cache. Survives close-and-reopen of the right sidebar. - -### Review fixes (post-review) - -- [x] **Blocker**: `inFlightDirsRef` leak — removed `isCancelled` guard from `finally` so inflight flag always cleans up. Re-expanding a cancelled directory correctly retries. -- [x] **Non-blocker**: duplicate `RIGHT_SIDEBAR_MAX_WIDTH` — exported from `useUIStore`, imported in `RightSidebar`. Single source of truth. -- [x] **Non-blocker**: `useRightSidebarGitSync` poll gating — verified `shouldPoll` already includes `rightTab === 'git'`. No code change needed. - -## Files modified - -| File | Change | -|------|--------| -| `packages/ui/src/components/layout/RightSidebar.tsx` | Drop dead useEffect, collapse width props, import constants from store | -| `packages/ui/src/components/layout/RightSidebarTabs.tsx` | Always-mount tabs, gated poll, redirect effect, type-guarded onSelect | -| `packages/ui/src/components/layout/SidebarFilesTree.tsx` | Precomputed maps, React.memo(FileRow), isCancelled predicate, LRU cache | -| `packages/ui/src/components/git/GitView.tsx` | Cancelled + Promise.all, isGitViewActive, useShallow, LRU snapshots | -| `packages/ui/src/components/project/ProjectNotesTodoPanel.tsx` | Debounce cancel on blur, chained persistProjectData | -| `packages/ui/src/stores/useUIStore.ts` | Clamp setRightSidebarWidth, simplify setRightSidebarOpen, export constants | -| `packages/ui/src/hooks/useGitmojiList.ts` | **New** — module-level inflight + subscribers, localStorage cache | - -## Architecture notes - -- The redirect effect snaps `rightSidebarTab` to `'files'` whenever `activeMainTab === 'git'`, so the right and main `GitView` instances are mutually exclusive — `isGitViewActive` cannot be true for both. -- The 7 gated effects plus the `useRightSidebarGitSync` poll cover all cases where git state should advance: visible consumer fetches; the poll keeps the store warm when only the right git tab is visible. -- The `loadDirectory` cancellation predicate prevents stale state writes in `try`/`catch`; the `finally` block always cleans up `inFlightDirsRef`. - -## Out of scope (deferred) - -- Virtualization of `SidebarFilesTree` (large refactor; current precomputed maps already address the main bottlenecks). -- Lazy plan titles (requires `openchamberConfig` schema changes). -- Extracting remaining sub-views from `GitView` (large refactor, not perf-critical). diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index dda0700a..dcb2465f 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1,5 +1,7 @@ import React from 'react'; import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; +import type { PermissionRequest } from '@/types/permission'; +import type { QuestionRequest } from '@/types/question'; import { ChatInput } from './ChatInput'; import { DraftPresetChips } from './DraftPresetChips'; @@ -23,14 +25,8 @@ import { useDeviceInfo } from '@/lib/device'; import { Button } from '@/components/ui/button'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { Icon } from "@/components/icon/Icon"; -import type { PermissionRequest } from '@/types/permission'; -import type { QuestionRequest } from '@/types/question'; import { cn, formatDirectoryName } from '@/lib/utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { - collectVisibleSessionIdsForBlockingRequests, - flattenBlockingRequests, -} from './lib/blockingRequests'; // New sync system imports import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -38,22 +34,21 @@ import { useStreamingStore } from '@/sync/streaming'; import { useSessionMessageCount, useSessionMessageRecords, - useSessions, - useDirectorySync, useSyncDirectory, + useDirectorySync, useSessionStatus, + useScopedBlockingPermissions, + useScopedBlockingQuestions, + useParentSession, } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache'; import { getSessionMaterializationStatus } from '@/sync/materialization'; import { usePlanDetection } from '@/hooks/usePlanDetection'; -import { getAllSyncSessions } from '@/sync/sync-refs'; import { useI18n } from '@/lib/i18n'; import { isVSCodeRuntime } from '@/lib/desktop'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; -const EMPTY_PERMISSIONS: PermissionRequest[] = []; -const EMPTY_QUESTIONS: QuestionRequest[] = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom'; const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; @@ -140,6 +135,7 @@ type ChatViewportProps = { isDesktopExpandedInput: boolean; isMobile: boolean; stickyUserHeader: boolean; + directory?: string; scrollRef: React.RefObject; messageListRef: React.RefObject; pendingRevealWork: boolean; @@ -168,6 +164,7 @@ const ChatViewport = React.memo(({ isDesktopExpandedInput, isMobile, stickyUserHeader, + directory, scrollRef, messageListRef, pendingRevealWork, @@ -235,6 +232,7 @@ const ChatViewport = React.memo(({ isLoadingOlder={isLoadingOlder} scrollToBottom={scrollToBottom} scrollRef={scrollRef} + directory={directory} /> {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
@@ -263,6 +261,7 @@ const ChatViewport = React.memo(({ && prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isMobile === next.isMobile && prev.stickyUserHeader === next.stickyUserHeader + && prev.directory === next.directory && prev.scrollRef === next.scrollRef && prev.messageListRef === next.messageListRef && prev.pendingRevealWork === next.pendingRevealWork @@ -407,7 +406,10 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr effectiveSessionDirectory, ); // Messages from sync system - const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory); + const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, { + suspendPartUpdates: Boolean(streamingMessageId), + suspendPartUpdatesForMessageId: streamingMessageId, + }); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; const sessionPrefetchInfo = React.useSyncExternalStore( React.useCallback( @@ -423,55 +425,18 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr React.useCallback(() => undefined, []), ); - // Sessions from sync system - const sessions = useSessions(effectiveSessionDirectory); - // Plan detection - watches messages for plan creation and signals store usePlanDetection(currentSessionId ?? '', sessionMessages); // Session status from sync system const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '', effectiveSessionDirectory) ?? IDLE_SESSION_STATUS; - // Permissions & questions from sync system - const allPermissions = useDirectorySync( - React.useCallback((s) => s.permission ?? {}, []), - effectiveSessionDirectory, - ); - const allQuestions = useDirectorySync( - React.useCallback((s) => s.question ?? {}, []), - effectiveSessionDirectory, - ); + // Scoped blocking requests — only subscribe to permissions/questions for + // the current session + descendant subagent sessions, not all sessions in + // the directory. + const sessionPermissions = useScopedBlockingPermissions(currentSessionId, effectiveSessionDirectory); + const sessionQuestions = useScopedBlockingQuestions(currentSessionId, effectiveSessionDirectory); - // Convert Record → Map for blockingRequests helpers - const permissionsMap = React.useMemo(() => { - const m = new Map(); - for (const [k, v] of Object.entries(allPermissions)) m.set(k, v as PermissionRequest[]); - return m; - }, [allPermissions]); - - const questionsMap = React.useMemo(() => { - const m = new Map(); - for (const [k, v] of Object.entries(allQuestions)) m.set(k, v as QuestionRequest[]); - return m; - }, [allQuestions]); - - const scopedSessionIds = React.useMemo( - () => collectVisibleSessionIdsForBlockingRequests( - sessions.map((session) => ({ id: session.id, parentID: session.parentID })), - currentSessionId, - ), - [sessions, currentSessionId], - ); - - const sessionPermissions = React.useMemo(() => { - if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS; - return flattenBlockingRequests(permissionsMap, scopedSessionIds); - }, [permissionsMap, scopedSessionIds]); - - const sessionQuestions = React.useMemo(() => { - if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS; - return flattenBlockingRequests(questionsMap, scopedSessionIds); - }, [questionsMap, scopedSessionIds]); const sessionIsWorking = React.useMemo(() => { if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) { return false; @@ -561,15 +526,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr return project ? getProjectDisplayLabel(project) : null; }, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); - const parentSession = React.useMemo(() => { - if (!currentSessionId) return null; - const current = sessions.find((session) => session.id === currentSessionId); - const parentID = current?.parentID; - if (!parentID) return null; - return sessions.find((session) => session.id === parentID) - ?? getAllSyncSessions().find((session) => session.id === parentID) - ?? null; - }, [currentSessionId, sessions]); + const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); const handleReturnToParentSession = React.useCallback(() => { if (!parentSession) return; @@ -943,6 +900,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr isDesktopExpandedInput={isDesktopExpandedInput} isMobile={isMobile} stickyUserHeader={stickyUserHeader} + directory={effectiveSessionDirectory} scrollRef={scrollRef} messageListRef={messageListRef} pendingRevealWork={timelineController.pendingRevealWork} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index c1381851..51ab068f 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -74,6 +74,7 @@ import { sessionEvents } from '@/lib/sessionEvents'; import { fetchResponseStyleInstruction } from '@/lib/responseStyle'; import { wrapSystemReminder } from '@/lib/systemReminder'; import { getSyncMessages } from '@/sync/sync-refs'; +import { EMPTY_REVERTED_MESSAGE_DOCK_STATE, buildRevertedMessageDockState, type RevertedMessageDockState } from './revertedMessageDockState'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; import { isSyntheticPart } from '@/lib/messages/synthetic'; import { @@ -89,11 +90,10 @@ import { buildAttachmentCitationText, findAttachmentCitationRanges, } from './attachmentCitations'; -import type { Message, Part } from '@opencode-ai/sdk/v2/client'; +import type { Part } from '@opencode-ai/sdk/v2/client'; const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; -const EMPTY_MESSAGES: Message[] = []; const FILE_MENTION_TOKEN = /^@[^\s]+$/; // Single-line URL pasted over a selection becomes a markdown link. const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i; @@ -363,34 +363,28 @@ const RevertedMessageDock: React.FC = React.memo(({ se const [restoringId, setRestoringId] = React.useState(null); const [forkingId, setForkingId] = React.useState(null); const [collapsed, setCollapsed] = React.useState(true); - const revertMessageID = useDirectorySync( + const revertedStateRef = React.useRef(EMPTY_REVERTED_MESSAGE_DOCK_STATE); + const revertedState = useDirectorySync( React.useCallback((state) => { - if (!sessionId) return undefined; - const session = state.session.find((item) => item.id === sessionId); - return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID; + const next = buildRevertedMessageDockState(state, sessionId, revertedStateRef.current); + revertedStateRef.current = next; + return next; }, [sessionId]), directory, ); - const sessionMessages = useDirectorySync( - React.useCallback((state) => (sessionId ? state.message[sessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES), [sessionId]), - directory, - ); - const partsByMessage = useDirectorySync(React.useCallback((state) => state.part, []), directory); - + const revertMessageID = revertedState.revertMessageID; const userMessages = React.useMemo( - () => sessionMessages.filter((message): message is Message & { role: 'user' } => message.role === 'user'), - [sessionMessages], + () => revertedState.records.map((record) => record.message), + [revertedState], ); const noTextContent = t('chat.revertPopover.noTextContent'); const items = React.useMemo(() => { if (!revertMessageID) return []; - return userMessages - .filter((message) => message.id >= revertMessageID) - .map((message) => ({ - id: message.id, - text: getRevertedPreview(partsByMessage[message.id] ?? [], noTextContent), - })); - }, [noTextContent, partsByMessage, revertMessageID, userMessages]); + return revertedState.records.map((record) => ({ + id: record.message.id, + text: getRevertedPreview(record.parts, noTextContent), + })); + }, [noTextContent, revertMessageID, revertedState]); const firstRevertedMessageId = items[0]?.id; React.useEffect(() => { diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 1b91a5db..b050fd25 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import 'katex/dist/katex.min.css'; import morphdom from 'morphdom'; import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; import type { Part } from '@opencode-ai/sdk/v2'; @@ -19,7 +18,7 @@ import type { EditorAPI } from '@/lib/api/types'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; -import { fallbackHtml, renderMarkdownBlocks } from './markdown/markdownCore'; +import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme'; import { attachMarkdownInteractions, @@ -1032,10 +1031,16 @@ const useMorphdomMarkdown = ({ // `display:contents` keeps margin-collapsing/spacing identical to a flat // HTML body — the wrapper exists only for per-block reconciliation. block.style.display = 'contents'; - block.innerHTML = fallbackHtml(text); + block.innerHTML = renderMarkdownSync(text); + // Decorate synchronously too: wrap code blocks in their framed card, + // mark inline code, build table controls, etc. The async pass re-decorates + // its own DOM before morphing, so without this the first paint shows bare + //
/tables that "snap" into their decorated form a tick later. Matching
+      // the structure here keeps the async morph to syntax colors only.
+      decorateMarkdown(block, ctx);
       target.appendChild(block);
     }
-  }, [containerRef, text]);
+  }, [containerRef, text, ctx]);
 
   React.useEffect(() => {
     const container = containerRef.current;
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx
index 2489c2a4..8957f216 100644
--- a/packages/ui/src/components/chat/MessageList.tsx
+++ b/packages/ui/src/components/chat/MessageList.tsx
@@ -6,19 +6,19 @@ import ChatMessage from './ChatMessage';
 import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
 import TurnItem from './components/TurnItem';
 import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
-import { filterSyntheticParts } from '@/lib/messages/synthetic';
 import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
 import { useTurnRecords } from './hooks/useTurnRecords';
 import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
+import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
+import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
 import { useUIStore } from '@/stores/useUIStore';
 import { FadeInDisabledProvider } from './message/FadeInOnReveal';
 import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
 import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
 import type { StreamPhase } from './message/types';
-import { normalizeParts } from './message/partUtils';
 import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
-import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
-import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
+import { useSessionParts } from '@/sync/sync-context';
+import type { ReviewTransferDirection } from '@/lib/reviewFlow';
 
 const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
 const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
@@ -88,13 +88,6 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => {
         ?? null;
 };
 
-const hasCompactionPart = (message: ChatMessageEntry): boolean => {
-    return message.parts.some((part) => {
-        const type = (part as { type?: unknown } | null | undefined)?.type;
-        return type === 'compaction';
-    });
-};
-
 const getPartText = (part: Part): string => {
     const text = (part as { text?: unknown }).text;
     if (typeof text === 'string') {
@@ -107,40 +100,6 @@ const getPartText = (part: Part): string => {
     return '';
 };
 
-const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => {
-    if (!hasCompactionPart(message)) {
-        return message;
-    }
-
-    let changedParts = false;
-    const nextParts = message.parts.map((part) => {
-        const type = (part as { type?: unknown } | null | undefined)?.type;
-        if (type !== 'compaction') {
-            return part;
-        }
-        changedParts = true;
-        return { type: 'text', text: '/compact' } as Part;
-    });
-
-    const info = message.info as unknown as { clientRole?: string | null | undefined };
-    const needsClientRole = info.clientRole !== 'user';
-
-    if (!changedParts && !needsClientRole) {
-        return message;
-    }
-
-    return {
-        ...message,
-        info: needsClientRole
-            ? ({
-                ...(message.info as unknown as Record),
-                clientRole: 'user',
-            } as unknown as typeof message.info)
-            : message.info,
-        parts: changedParts ? nextParts : message.parts,
-    };
-};
-
 const normalizeCompactionSummaryMessage = (
     message: ChatMessageEntry,
     compactionCommandIds: Set,
@@ -391,39 +350,6 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
     };
 };
 
-const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => {
-    const parts = normalizeParts(message.parts);
-    if (parts.length === message.parts.length) {
-        return message;
-    }
-    return {
-        ...message,
-        parts,
-    };
-};
-
-const normalizedMessageBySource = new WeakMap();
-
-const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
-    const cached = normalizedMessageBySource.get(message);
-    if (cached) {
-        return cached;
-    }
-
-    const normalizedPartMessage = normalizeMessageParts(message);
-    const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage);
-    const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
-    const normalized = filteredParts === normalizedCompactionMessage.parts
-        ? normalizedCompactionMessage
-        : {
-            ...normalizedCompactionMessage,
-            parts: filteredParts,
-        };
-
-    normalizedMessageBySource.set(message, normalized);
-    return normalized;
-};
-
 interface MessageListProps {
     sessionKey: string;
     disableStaging?: boolean;
@@ -442,6 +368,7 @@ interface MessageListProps {
     isLoadingOlder: boolean;
     scrollToBottom?: () => void;
     scrollRef?: React.RefObject;
+    directory?: string;
 }
 
 export interface MessageListHandle {
@@ -1007,11 +934,10 @@ type StaticHistoryListProps = {
     chatRenderMode: 'sorted' | 'live';
     shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
     onUserAnimationConsumed: (messageId: string) => void;
-    activeStreamingPhase?: StreamPhase | null;
     reviewTransferDirection?: ReviewTransferDirection | null;
 };
 
-const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase, reviewTransferDirection }: StaticHistoryListProps) => {
+const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
     const renderEntry = React.useCallback((entry: RenderEntry) => {
         return (
             
         );
-    }, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
+    }, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
 
     if (!shouldVirtualize) {
         return (
@@ -1074,6 +1000,7 @@ StaticHistoryList.displayName = 'StaticHistoryList';
 
 const StreamingTailContent: React.FC<{
     entry: RenderEntry;
+    directory?: string;
     onMessageContentChange: (reason?: ContentChangeReason) => void;
     getAnimationHandlers: (messageId: string) => AnimationHandlers;
     scrollToBottom?: () => void;
@@ -1083,6 +1010,7 @@ const StreamingTailContent: React.FC<{
     turnUiStates: Map;
     onToggleTurnGroup: (turnId: string) => void;
     chatRenderMode: 'sorted' | 'live';
+    showTurnChangedFiles: boolean;
     shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
     onUserAnimationConsumed: (messageId: string) => void;
     activeStreamingMessageId?: string | null;
@@ -1090,6 +1018,7 @@ const StreamingTailContent: React.FC<{
     reviewTransferDirection?: ReviewTransferDirection | null;
 }> = ({
     entry,
+    directory,
     onMessageContentChange,
     getAnimationHandlers,
     scrollToBottom,
@@ -1099,15 +1028,24 @@ const StreamingTailContent: React.FC<{
     turnUiStates,
     onToggleTurnGroup,
     chatRenderMode,
+    showTurnChangedFiles,
     shouldAnimateUserMessage,
     onUserAnimationConsumed,
     activeStreamingMessageId,
     activeStreamingPhase,
     reviewTransferDirection,
 }) => {
+    const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
+    const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
+        activeStreamingMessageId,
+        liveParts,
+        showTextJustificationActivity: chatRenderMode === 'sorted',
+        showTurnChangedFiles,
+    }), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
+
     return (
         (({
     isLoadingOlder,
     scrollToBottom,
     scrollRef,
+    directory,
 }, ref) => {
     streamPerfCount('ui.message_list.render');
     const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
@@ -1149,18 +1088,7 @@ const MessageList = React.forwardRef(({
     const showTurnChangedFiles = useUIStore((state) => state.showTurnChangedFiles);
     const defaultActivityExpanded = activityRenderMode === 'summary';
     const reviewTransferDirection = useGlobalSessionsStore((state) => {
-        const currentSession = state.activeSessions.find((session) => session.id === sessionKey);
-        const direction = getReviewTransferDirection(currentSession);
-        if (!currentSession || !direction) return null;
-
-        const targetSessionId = direction === 'review-to-original'
-            ? getOriginalSessionID(currentSession)
-            : getReviewSessionID(currentSession);
-        if (!targetSessionId) return null;
-
-        return state.activeSessions.some((session) => session.id === targetSessionId)
-            ? direction
-            : null;
+        return state.reviewTransferBySessionId.get(sessionKey) ?? null;
     });
     const [turnUiStates, setTurnUiStates] = React.useState>(() => new Map());
     const userAnimationRef = React.useRef<{
@@ -1665,12 +1593,12 @@ const MessageList = React.forwardRef(({
                             chatRenderMode={chatRenderMode}
                             shouldAnimateUserMessage={shouldAnimateUserMessage}
                             onUserAnimationConsumed={onUserAnimationConsumed}
-                            activeStreamingPhase={activeStreamingPhase}
                             reviewTransferDirection={reviewTransferDirection}
                         />
                         {trailingStreamingEntry ? (
                             (({
                                 turnUiStates={turnUiStates}
                                 onToggleTurnGroup={toggleTurnGroup}
                                 chatRenderMode={chatRenderMode}
+                                showTurnChangedFiles={showTurnChangedFiles}
                                 shouldAnimateUserMessage={shouldAnimateUserMessage}
                                 onUserAnimationConsumed={onUserAnimationConsumed}
                                 activeStreamingMessageId={activeStreamingMessageId}
diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
index 5af98f8f..cf437015 100644
--- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
+++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts
@@ -334,24 +334,67 @@ export const useChatTimelineController = ({
         return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
     }, [messageListRef]);
 
+    // Tracks the timeline edges + height of the previous commit so a prepend
+    // that did NOT go through fetchOlderHistory (e.g. the background history
+    // prepend dispatched from useSync) can be compensated too. With
+    // overflow-anchor:none the browser leaves scrollTop unchanged when content
+    // is inserted above, so without this the viewport visibly jumps and
+    // auto-follow yanks it back on the next frame — a one-shot up/down judder.
+    const prependTrackingRef = React.useRef<{
+        oldestId: string | null;
+        newestId: string | null;
+        scrollHeight: number;
+    } | null>(null);
+
     React.useLayoutEffect(() => {
-        const snap = prePrependScrollRef.current;
         const container = scrollRef.current;
-        if (!snap || !container) return;
-        prePrependScrollRef.current = null;
+        if (!container) return;
 
-        // When a viewport anchor is available, delegate to MessageList
-        // restoreViewportAnchor which falls back to virtualizer-aware
-        // scrollHistoryIndexIntoView when the element is not in the DOM.
-        if (snap.anchor && restoreViewportAnchor(snap.anchor)) {
-            return;
+        const snap = prePrependScrollRef.current;
+        if (snap) {
+            prePrependScrollRef.current = null;
+            // When a viewport anchor is available, delegate to MessageList
+            // restoreViewportAnchor which falls back to virtualizer-aware
+            // scrollHistoryIndexIntoView when the element is not in the DOM.
+            if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
+                // Fallback: height-delta compensation
+                const delta = container.scrollHeight - snap.height;
+                if (delta > 0) {
+                    container.scrollTop = snap.top + delta;
+                }
+            }
+        } else {
+            // Auto-detect a prepend: the oldest message changed while the newest
+            // stayed the same (distinguishes a real prepend from a session
+            // switch, a bottom append, or a streaming part growing). Compensate
+            // synchronously by the exact height delta — for a bottom-pinned
+            // viewport this keeps it pinned, for a released one it preserves the
+            // read position, with no intermediate frame for auto-follow to fight.
+            const prev = prependTrackingRef.current;
+            const currentOldestId = renderedMessages[0]?.info?.id ?? null;
+            const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
+            const isPrepend = Boolean(
+                prev
+                && prev.oldestId
+                && currentOldestId
+                && currentOldestId !== prev.oldestId
+                && prev.newestId
+                && currentNewestId
+                && currentNewestId === prev.newestId,
+            );
+            if (isPrepend && prev) {
+                const delta = container.scrollHeight - prev.scrollHeight;
+                if (delta > 0) {
+                    container.scrollTop = container.scrollTop + delta;
+                }
+            }
         }
 
-        // Fallback: height-delta compensation
-        const delta = container.scrollHeight - snap.height;
-        if (delta > 0) {
-            container.scrollTop = snap.top + delta;
-        }
+        prependTrackingRef.current = {
+            oldestId: renderedMessages[0]?.info?.id ?? null,
+            newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null,
+            scrollHeight: container.scrollHeight,
+        };
     }, [renderedMessages, scrollRef, restoreViewportAnchor]);
 
     const revealBufferedTurns = React.useCallback(async (): Promise => false, []);
diff --git a/packages/ui/src/components/chat/hooks/useTurnRecords.ts b/packages/ui/src/components/chat/hooks/useTurnRecords.ts
index 9538db37..a7b94938 100644
--- a/packages/ui/src/components/chat/hooks/useTurnRecords.ts
+++ b/packages/ui/src/components/chat/hooks/useTurnRecords.ts
@@ -1,6 +1,7 @@
 import React from 'react';
 import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
 import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
+import { buildProjectionCacheKey, getCachedProjection, setCachedProjection } from '../lib/turns/turnProjectionCache';
 import { streamPerfMeasure } from '@/stores/utils/streamDebug';
 
 interface UseTurnRecordsOptions {
@@ -46,6 +47,18 @@ export const useTurnRecords = (
     }, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]);
 
     const projection = React.useMemo(() => {
+        const sessionKey = options.sessionKey ?? '';
+        const cached = getCachedProjection(
+            sessionKey,
+            messages,
+            options.showTextJustificationActivity,
+            options.showTurnChangedFiles,
+        );
+        if (cached) {
+            previousProjectionRef.current = cached;
+            return cached;
+        }
+
         return streamPerfMeasure('ui.turns.projection_ms', () => {
             const nextProjection = projectTurnRecords(messages, {
                 previousProjection: previousProjectionRef.current,
@@ -53,9 +66,18 @@ export const useTurnRecords = (
                 showTurnChangedFiles: options.showTurnChangedFiles,
             });
             previousProjectionRef.current = nextProjection;
+
+            const cacheKey = buildProjectionCacheKey(
+                sessionKey,
+                messages,
+                options.showTextJustificationActivity,
+                options.showTurnChangedFiles,
+            );
+            setCachedProjection(cacheKey, nextProjection);
+
             return nextProjection;
         });
-    }, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles]);
+    }, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey]);
 
     const staticTurns = React.useMemo(() => {
         const nextStatic = projection.turns.length <= 1
diff --git a/packages/ui/src/components/chat/lib/messageDisplayNormalization.ts b/packages/ui/src/components/chat/lib/messageDisplayNormalization.ts
new file mode 100644
index 00000000..85a862c3
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/messageDisplayNormalization.ts
@@ -0,0 +1,79 @@
+import type { Part } from '@opencode-ai/sdk/v2';
+
+import { filterSyntheticParts } from '@/lib/messages/synthetic';
+import { normalizeParts } from '../message/partUtils';
+import type { ChatMessageEntry } from './turns/types';
+
+export const hasCompactionPart = (message: ChatMessageEntry): boolean => {
+    return message.parts.some((part) => {
+        const type = (part as { type?: unknown } | null | undefined)?.type;
+        return type === 'compaction';
+    });
+};
+
+const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => {
+    if (!hasCompactionPart(message)) {
+        return message;
+    }
+
+    let changedParts = false;
+    const nextParts = message.parts.map((part) => {
+        const type = (part as { type?: unknown } | null | undefined)?.type;
+        if (type !== 'compaction') {
+            return part;
+        }
+        changedParts = true;
+        return { type: 'text', text: '/compact' } as Part;
+    });
+
+    const info = message.info as unknown as { clientRole?: string | null | undefined };
+    const needsClientRole = info.clientRole !== 'user';
+
+    if (!changedParts && !needsClientRole) {
+        return message;
+    }
+
+    return {
+        ...message,
+        info: needsClientRole
+            ? ({
+                ...(message.info as unknown as Record),
+                clientRole: 'user',
+            } as unknown as typeof message.info)
+            : message.info,
+        parts: changedParts ? nextParts : message.parts,
+    };
+};
+
+const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => {
+    const parts = normalizeParts(message.parts);
+    if (parts.length === message.parts.length) {
+        return message;
+    }
+    return {
+        ...message,
+        parts,
+    };
+};
+
+const normalizedMessageBySource = new WeakMap();
+
+export const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
+    const cached = normalizedMessageBySource.get(message);
+    if (cached) {
+        return cached;
+    }
+
+    const normalizedPartMessage = normalizeMessageParts(message);
+    const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage);
+    const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
+    const normalized = filteredParts === normalizedCompactionMessage.parts
+        ? normalizedCompactionMessage
+        : {
+            ...normalizedCompactionMessage,
+            parts: filteredParts,
+        };
+
+    normalizedMessageBySource.set(message, normalized);
+    return normalized;
+};
diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts
index 20f94859..6eb32053 100644
--- a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts
+++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts
@@ -106,6 +106,27 @@ describe('projectTurnRecords', () => {
         expect(next.turns[1]).not.toBe(initial.turns[1]);
     });
 
+    test('hydrates updated turns when a previous projection exists but no turn is reusable', () => {
+        const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
+        const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
+        const initial = projectTurnRecords([user, assistant]);
+        const updatedAssistant = {
+            ...assistant,
+            parts: [{ id: 'tool_1', type: 'tool', tool: 'bash', state: { status: 'completed' } } as Part],
+        };
+
+        const next = projectTurnRecords([user, updatedAssistant], {
+            previousProjection: initial,
+        });
+
+        expect(next.turns).toHaveLength(1);
+        expect(next.turns[0]).not.toBe(initial.turns[0]);
+        expect(next.turns[0]?.hasTools).toBe(true);
+        expect(next.turns[0]?.activityParts).toHaveLength(1);
+        expect(next.turns[0]?.stream.isStreaming).toBe(true);
+        expect(next.turns[0]?.stream.isRetrying).toBe(false);
+    });
+
     test('reuses the whole turns array when every turn is unchanged', () => {
         const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
         const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts
index d9eb27dc..bdff1b45 100644
--- a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts
+++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts
@@ -115,12 +115,43 @@ const canReusePreviousTurn = (previous: TurnRecord, next: TurnRecord): boolean =
         && areSameMessageRefs(previous.assistantMessages, next.assistantMessages);
 };
 
-const stabilizeTurnRecords = (
+const hydrateTurnRecord = (
+    turn: TurnRecord,
+    effectiveOptions: ProjectTurnRecordsOptions,
+): TurnRecord => {
+    turn.summary = projectTurnSummary(turn.assistantMessages);
+    turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
+    turn.diffStats = projectTurnDiffStats(turn.userMessage);
+    turn.changedFiles = effectiveOptions.showTurnChangedFiles
+        ? projectTurnChangedFiles(turn.userMessage)
+        : undefined;
+
+    const activity = projectTurnActivity({
+        turnId: turn.turnId,
+        assistantMessages: turn.assistantMessages,
+        summarySourceMessageId: turn.summary.sourceMessageId,
+        summarySourcePartId: turn.summary.sourcePartId,
+        showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
+    });
+    turn.activityParts = activity.activityParts;
+    turn.activitySegments = activity.activitySegments;
+    turn.hasTools = activity.hasTools;
+    turn.hasReasoning = activity.hasReasoning;
+
+    turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
+    turn.startedAt = turn.stream.startedAt;
+    turn.completedAt = turn.stream.completedAt;
+    turn.durationMs = turn.stream.durationMs;
+    return turn;
+};
+
+const hydrateStableTurnRecords = (
     turns: TurnRecord[],
-    previousProjection?: TurnProjectionResult | null,
+    effectiveOptions: ProjectTurnRecordsOptions,
 ): TurnRecord[] => {
+    const previousProjection = effectiveOptions.previousProjection;
     if (!previousProjection || previousProjection.turns.length === 0 || turns.length === 0) {
-        return turns;
+        return turns.map((turn) => hydrateTurnRecord(turn, effectiveOptions));
     }
 
     let canReuseTurnArray = previousProjection.turns.length === turns.length;
@@ -137,14 +168,14 @@ const stabilizeTurnRecords = (
         }
 
         canReuseTurnArray = false;
-        return turn;
+        return hydrateTurnRecord(turn, effectiveOptions);
     });
 
     if (canReuseTurnArray && reusedAnyTurn) {
         return previousProjection.turns;
     }
 
-    return reusedAnyTurn ? nextTurns : turns;
+    return nextTurns;
 };
 
 export const projectTurnRecords = (
@@ -214,33 +245,7 @@ export const projectTurnRecords = (
         groupedMessageIds.add(message.info.id);
     });
 
-    turns.forEach((turn) => {
-        turn.summary = projectTurnSummary(turn.assistantMessages);
-        turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
-        turn.diffStats = projectTurnDiffStats(turn.userMessage);
-        turn.changedFiles = effectiveOptions.showTurnChangedFiles
-            ? projectTurnChangedFiles(turn.userMessage)
-            : undefined;
-
-        const activity = projectTurnActivity({
-            turnId: turn.turnId,
-            assistantMessages: turn.assistantMessages,
-            summarySourceMessageId: turn.summary.sourceMessageId,
-            summarySourcePartId: turn.summary.sourcePartId,
-            showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
-        });
-        turn.activityParts = activity.activityParts;
-        turn.activitySegments = activity.activitySegments;
-        turn.hasTools = activity.hasTools;
-        turn.hasReasoning = activity.hasReasoning;
-
-        turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
-        turn.startedAt = turn.stream.startedAt;
-        turn.completedAt = turn.stream.completedAt;
-        turn.durationMs = turn.stream.durationMs;
-    });
-
-    const stableTurns = stabilizeTurnRecords(turns, effectiveOptions.previousProjection);
+    const stableTurns = hydrateStableTurnRecords(turns, effectiveOptions);
     const projection = projectTurnIndexes(stableTurns);
     const ungroupedMessageIds = new Set();
     messages.forEach((message) => {
diff --git a/packages/ui/src/components/chat/lib/turns/streamingTailEntry.test.ts b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.test.ts
new file mode 100644
index 00000000..ed674707
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, test } from 'bun:test';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+
+import { buildLiveStreamingEntry, type StreamingTailEntry } from './streamingTailEntry';
+import type { ChatMessageEntry, TurnRecord } from './types';
+
+const message = (id: string, role: 'user' | 'assistant', parentID?: string, parts: Part[] = []): ChatMessageEntry => ({
+    info: {
+        id,
+        role,
+        sessionID: 'ses_1',
+        ...(parentID ? { parentID } : {}),
+        time: { created: 1 },
+    } as Message,
+    parts,
+});
+
+const textPart = (id: string, text: string): Part => ({
+    id,
+    type: 'text',
+    text,
+} as Part);
+
+const syntheticTextPart = (id: string, text: string): Part => ({
+    id,
+    type: 'text',
+    text,
+    synthetic: true,
+} as Part);
+
+const reasoningPart = (id: string, text: string): Part => ({
+    id,
+    type: 'reasoning',
+    text,
+} as Part);
+
+const turnEntry = (assistant: ChatMessageEntry): StreamingTailEntry => {
+    const user = message('user_1', 'user');
+    return {
+        kind: 'turn',
+        key: 'turn:user_1',
+        isLastTurn: true,
+        turn: {
+            turnId: 'user_1',
+            userMessageId: 'user_1',
+            userMessage: user,
+            headerMessageId: assistant.info.id,
+            messages: [],
+            assistantMessageIds: [assistant.info.id],
+            assistantMessages: [assistant],
+            activityParts: [],
+            activitySegments: [],
+            summary: {},
+            hasTools: false,
+            hasReasoning: false,
+            stream: { isStreaming: true, isRetrying: false },
+        } satisfies TurnRecord,
+    };
+};
+
+describe('buildLiveStreamingEntry', () => {
+    test('returns the same entry when the active message is not in the tail', () => {
+        const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'old')]);
+        const entry = turnEntry(assistant);
+
+        const next = buildLiveStreamingEntry(entry, {
+            activeStreamingMessageId: 'assistant_other',
+            liveParts: [textPart('part_live', 'live')],
+            showTextJustificationActivity: true,
+            showTurnChangedFiles: false,
+        });
+
+        expect(next).toBe(entry);
+    });
+
+    test('rebuilds only the streaming turn with live parts', () => {
+        const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'hel')]);
+        const entry = turnEntry(assistant);
+        const liveParts = [reasoningPart('part_1_live', 'thinking')];
+
+        const next = buildLiveStreamingEntry(entry, {
+            activeStreamingMessageId: 'assistant_1',
+            liveParts,
+            showTextJustificationActivity: true,
+            showTurnChangedFiles: false,
+        });
+
+        expect(next).not.toBe(entry);
+        expect(next.kind).toBe('turn');
+        if (next.kind !== 'turn') return;
+        expect(next.turn.assistantMessages[0]?.parts).toBe(liveParts);
+        expect(next.turn.activityParts.length).toBeGreaterThan(0);
+    });
+
+    test('updates an ungrouped streaming message with live parts', () => {
+        const stale = message('assistant_1', 'assistant', undefined, [textPart('part_1', 'old')]);
+        const entry: StreamingTailEntry = {
+            kind: 'ungrouped',
+            key: 'msg:assistant_1',
+            message: stale,
+        };
+        const liveParts = [textPart('part_1_live', 'live')];
+
+        const next = buildLiveStreamingEntry(entry, {
+            activeStreamingMessageId: 'assistant_1',
+            liveParts,
+            showTextJustificationActivity: false,
+            showTurnChangedFiles: false,
+        });
+
+        expect(next).not.toBe(entry);
+        expect(next.kind).toBe('ungrouped');
+        if (next.kind !== 'ungrouped') return;
+        expect(next.message.parts).toBe(liveParts);
+    });
+
+    test('normalizes live tail parts with the display filtering path', () => {
+        const stale = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'old')]);
+        const entry = turnEntry(stale);
+        const visible = textPart('part_visible', 'visible');
+        const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
+
+        const next = buildLiveStreamingEntry(entry, {
+            activeStreamingMessageId: 'assistant_1',
+            liveParts: [synthetic, visible],
+            showTextJustificationActivity: true,
+            showTurnChangedFiles: false,
+        });
+
+        expect(next.kind).toBe('turn');
+        if (next.kind !== 'turn') return;
+        expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
+    });
+});
diff --git a/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts
new file mode 100644
index 00000000..b1564452
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/turns/streamingTailEntry.ts
@@ -0,0 +1,86 @@
+import type { Part } from '@opencode-ai/sdk/v2';
+
+import { getNormalizedMessageForDisplay } from '../messageDisplayNormalization';
+import { projectTurnRecords } from './projectTurnRecords';
+import type { ChatMessageEntry, TurnRecord } from './types';
+
+export type StreamingTailEntry =
+    | {
+        kind: 'ungrouped';
+        key: string;
+        message: ChatMessageEntry;
+        previousMessage?: ChatMessageEntry;
+        nextMessage?: ChatMessageEntry;
+    }
+    | { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
+
+type BuildLiveStreamingEntryOptions = {
+    activeStreamingMessageId: string | null | undefined;
+    liveParts: Part[];
+    showTextJustificationActivity: boolean;
+    showTurnChangedFiles: boolean;
+};
+
+const withLiveParts = (
+    message: ChatMessageEntry,
+    activeStreamingMessageId: string,
+    liveParts: Part[],
+): ChatMessageEntry => {
+    if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
+        return message;
+    }
+
+    return getNormalizedMessageForDisplay({
+        ...message,
+        parts: liveParts,
+    });
+};
+
+export const buildLiveStreamingEntry = (
+    entry: TEntry,
+    options: BuildLiveStreamingEntryOptions,
+): TEntry => {
+    const activeStreamingMessageId = options.activeStreamingMessageId;
+    if (!activeStreamingMessageId) {
+        return entry;
+    }
+
+    if (entry.kind === 'ungrouped') {
+        const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
+        if (message === entry.message) {
+            return entry;
+        }
+        return {
+            ...entry,
+            message,
+        };
+    }
+
+    let changed = false;
+    const assistantMessages = entry.turn.assistantMessages.map((message) => {
+        const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
+        if (next !== message) {
+            changed = true;
+        }
+        return next;
+    });
+
+    if (!changed) {
+        return entry;
+    }
+
+    const projection = projectTurnRecords([entry.turn.userMessage, ...assistantMessages], {
+        showTextJustificationActivity: options.showTextJustificationActivity,
+        showTurnChangedFiles: options.showTurnChangedFiles,
+    });
+    const turn = projection.turns[0] ?? {
+        ...entry.turn,
+        assistantMessages,
+        assistantMessageIds: assistantMessages.map((message) => message.info.id),
+    };
+
+    return {
+        ...entry,
+        turn,
+    };
+};
diff --git a/packages/ui/src/components/chat/lib/turns/turnProjectionCache.test.ts b/packages/ui/src/components/chat/lib/turns/turnProjectionCache.test.ts
new file mode 100644
index 00000000..47471a9a
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/turns/turnProjectionCache.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from 'bun:test';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+import { buildProjectionCacheKey } from './turnProjectionCache';
+import type { ChatMessageEntry } from './types';
+
+const createEntry = (text: string): ChatMessageEntry => ({
+  info: { id: 'msg_1', role: 'assistant' } as Message,
+  parts: [{ id: 'prt_1', type: 'text', text } as Part],
+});
+
+describe('turnProjectionCache', () => {
+  test('keeps the cache key stable for unchanged message and part references', () => {
+    const messages = [createEntry('hello')];
+
+    const first = buildProjectionCacheKey('session_1', messages, false, false);
+    const second = buildProjectionCacheKey('session_1', messages, false, false);
+
+    expect(second).toBe(first);
+  });
+
+  test('changes the cache key when streaming replaces a part with the same id and count', () => {
+    const before = [createEntry('hel')];
+    const after = [
+      {
+        info: before[0].info,
+        parts: [{ id: 'prt_1', type: 'text', text: 'hello' } as Part],
+      },
+    ];
+
+    const beforeKey = buildProjectionCacheKey('session_1', before, false, false);
+    const afterKey = buildProjectionCacheKey('session_1', after, false, false);
+
+    expect(afterKey).not.toBe(beforeKey);
+  });
+});
diff --git a/packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts b/packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts
new file mode 100644
index 00000000..39c4c1ac
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts
@@ -0,0 +1,86 @@
+import type { ChatMessageEntry, TurnProjectionResult } from './types';
+import { isVSCodeRuntime } from '@/lib/desktop';
+import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
+
+const TURN_PROJECTION_CACHE_MAX = 30;
+const VSCODE_TURN_PROJECTION_CACHE_MAX = 4;
+const MOBILE_TURN_PROJECTION_CACHE_MAX = 4;
+
+const projectionCache = new Map();
+const objectVersionByRef = new WeakMap();
+let nextObjectVersion = 1;
+
+const getProjectionCacheMax = () => {
+  if (isVSCodeRuntime()) return VSCODE_TURN_PROJECTION_CACHE_MAX;
+  if (isMobileSurfaceRuntime()) return MOBILE_TURN_PROJECTION_CACHE_MAX;
+  return TURN_PROJECTION_CACHE_MAX;
+};
+
+const getObjectVersion = (value: object): number => {
+  const cached = objectVersionByRef.get(value);
+  if (cached !== undefined) return cached;
+  const next = nextObjectVersion;
+  nextObjectVersion += 1;
+  objectVersionByRef.set(value, next);
+  return next;
+};
+
+const buildMessagesVersionSignature = (messages: ChatMessageEntry[]): string => {
+  return messages.map((message) => {
+    const infoVersion = getObjectVersion(message.info as object);
+    const partsVersion = getObjectVersion(message.parts);
+    const partVersions = message.parts.map((part) => getObjectVersion(part as object)).join(',');
+    return `${infoVersion}:${partsVersion}:${partVersions}`;
+  }).join(';');
+};
+
+export const buildProjectionCacheKey = (
+  sessionKey: string,
+  messages: ChatMessageEntry[],
+  showTextJustificationActivity: boolean,
+  showTurnChangedFiles: boolean,
+): string => {
+  const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
+  const lastMessageId = lastMessage?.info?.id ?? '';
+  const lastMessagePartCount = lastMessage?.parts?.length ?? 0;
+  return [
+    sessionKey,
+    messages.length,
+    lastMessageId,
+    lastMessagePartCount,
+    buildMessagesVersionSignature(messages),
+    showTextJustificationActivity ? '1' : '0',
+    showTurnChangedFiles ? '1' : '0',
+  ].join('|');
+};
+
+export const getCachedProjection = (
+  sessionKey: string,
+  messages: ChatMessageEntry[],
+  showTextJustificationActivity: boolean,
+  showTurnChangedFiles: boolean,
+): TurnProjectionResult | undefined => {
+  const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles);
+  const cached = projectionCache.get(key);
+  if (cached) {
+    // LRU re-order: move hit to the end (most recent) so it survives
+    // eviction longer than entries that haven't been read recently.
+    projectionCache.delete(key);
+    projectionCache.set(key, cached);
+  }
+  return cached;
+};
+
+export const setCachedProjection = (
+  key: string,
+  projection: TurnProjectionResult,
+): void => {
+  projectionCache.delete(key);
+  const max = getProjectionCacheMax();
+  while (projectionCache.size >= max) {
+    const oldest = projectionCache.keys().next().value;
+    if (typeof oldest !== 'string') break;
+    projectionCache.delete(oldest);
+  }
+  projectionCache.set(key, projection);
+};
diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts
index be50928d..2e8467e4 100644
--- a/packages/ui/src/components/chat/markdown/decorate.ts
+++ b/packages/ui/src/components/chat/markdown/decorate.ts
@@ -88,7 +88,11 @@ const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void =>
     // Already wrapped (idempotent across morphdom passes).
     if (parent.closest('[data-component="markdown-code"]')) continue;
 
-    const language = pre.getAttribute('data-md-lang') ?? 'text';
+    // `data-md-lang` is stamped by the async highlight pass; on the synchronous
+    // first paint it isn't set yet, so fall back to the `language-*` class marked
+    // emits — keeps the card header label stable instead of flashing 'text'.
+    const classLang = pre.querySelector('code')?.className.match(/language-([\w+#.-]+)/)?.[1];
+    const language = pre.getAttribute('data-md-lang') ?? classLang ?? 'text';
 
     const wrapper = document.createElement('div');
     wrapper.setAttribute('data-component', 'markdown-code');
diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts
index b1ec558c..4fc1ce19 100644
--- a/packages/ui/src/components/chat/markdown/markdownCore.ts
+++ b/packages/ui/src/components/chat/markdown/markdownCore.ts
@@ -306,16 +306,6 @@ const sanitize = (html: string): string => {
   return DOMPurify.sanitize(html, SANITIZE_CONFIG) as unknown as string;
 };
 
-const escapeHtml = (text: string): string =>
-  text
-    .replace(/&/g, '&')
-    .replace(//g, '>')
-    .replace(/"/g, '"')
-    .replace(/'/g, ''');
-
-export const fallbackHtml = (markdown: string): string =>
-  escapeHtml(markdown).replace(/\r\n?/g, '\n').replace(/\n/g, '
'); // --------------------------------------------------------------------------- // Per-block HTML cache (LRU, mirrors OpenCode's checksum cache) @@ -349,6 +339,22 @@ const parseBlock = async (block: MarkdownBlock): Promise => { return sanitize(highlighted); }; +/** + * Synchronous styled render for the first paint, before the async pipeline + * (Shiki-in-worker highlight) resolves. Produces the SAME structural HTML as + * `renderMarkdownBlocks` minus syntax coloring: paragraphs, lists, code blocks + * and bold all render at their final width, so the async pass only upgrades + * code-block colors — no flash of full-width raw markdown source. `parser.parse` + * is synchronous (marked is not configured `async`), so this never blocks on a + * worker round-trip. + */ +export const renderMarkdownSync = (text: string): string => { + if (!text) return ''; + const parsed = parser.parse(text) as string; + const withMath = renderMathExpressions(parsed); + return sanitize(withMath); +}; + export type RenderedBlock = { // Stable identity across renders for per-block DOM reconciliation. Encodes // content + mode + highlight so any change forces that block (and only that diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index e5fde9a1..b862860b 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -12,11 +12,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectorySync, useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; -import { getSyncChildStores } from '@/sync/sync-refs'; import { useUIStore } from '@/stores/useUIStore'; -import { useSessionActivity } from '@/hooks/useSessionActivity'; -import { opencodeClient } from '@/lib/opencode/client'; -import { isVSCodeRuntime } from '@/lib/desktop'; import { sessionEvents } from '@/lib/sessionEvents'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Button } from '@/components/ui/button'; @@ -164,17 +160,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => { }; const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap -const TASK_TOOL_POLL_FAST_MS = 1200; -const TASK_TOOL_POLL_IDLE_MS = 3200; -const TASK_TOOL_POLL_HIDDEN_MS = 6000; -const TASK_TOOL_INITIAL_FETCH_LIMIT = 500; -const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160; -const TASK_TOOL_IDLE_FETCH_LIMIT = 80; -const VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT = 30; -const VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT = 30; -const VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT = 30; -const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3; -const TASK_TOOL_SETTLE_GRACE_MS = 2500; const TASK_TOOL_FALLBACK_RETRY_MS = 3000; const GIT_REFRESH_MUTATING_TOOLS = new Set([ 'bash', @@ -1025,41 +1010,6 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]) return entries; }; -const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => { - if (!Array.isArray(messages) || messages.length === 0) { - return '0'; - } - - const lastMessage = messages[messages.length - 1]; - const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : ''; - const lastMessageUpdated = - typeof lastMessage?.info?.time?.completed === 'number' - ? lastMessage.info.time.completed - : typeof lastMessage?.info?.time?.created === 'number' - ? lastMessage.info.time.created - : 0; - const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : []; - const lastPart = lastParts[lastParts.length - 1] as Record | undefined; - const tailType = typeof lastPart?.type === 'string' ? lastPart.type : ''; - const tailId = typeof lastPart?.id === 'string' ? lastPart.id : ''; - const tailTextLength = (() => { - const textCandidate = lastPart?.text; - if (typeof textCandidate === 'string') { - return textCandidate.length; - } - const stateCandidate = lastPart?.state; - if (stateCandidate && typeof stateCandidate === 'object') { - const stateStatus = (stateCandidate as Record).status; - if (typeof stateStatus === 'string') { - return stateStatus.length; - } - } - return 0; - })(); - - return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`; -}; - const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => { const title = entry.state?.title; if (typeof title === 'string' && title.trim().length > 0) { @@ -1117,6 +1067,144 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => { return /^[A-Za-z0-9_-]+$/.test(baseName); }; +const getTaskSummaryEntryRenderSignature = (entry: TaskToolSummaryEntry): string => { + const toolName = normalizeToolName(entry.tool); + const status = entry.state?.status ?? ''; + const label = getTaskSummaryLabel(entry); + return `${entry.id ?? ''}\u0001${toolName}\u0001${status}\u0001${label}`; +}; + +const areTaskSummaryEntriesRenderEqual = ( + prevEntries: TaskToolSummaryEntry[], + nextEntries: TaskToolSummaryEntry[], +): boolean => { + if (prevEntries === nextEntries) return true; + if (prevEntries.length !== nextEntries.length) return false; + for (let index = 0; index < prevEntries.length; index += 1) { + if (getTaskSummaryEntryRenderSignature(prevEntries[index]) !== getTaskSummaryEntryRenderSignature(nextEntries[index])) { + return false; + } + } + return true; +}; + +const TaskSummaryEntryRow = React.memo(({ + entry, + isMobile, + animateTailText, + showToolFileIcons, +}: { + entry: TaskToolSummaryEntry; + isMobile: boolean; + animateTailText: boolean; + showToolFileIcons: boolean; +}) => { + const normalizedToolName = normalizeToolName(entry.tool); + const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool'; + const label = getTaskSummaryLabel(entry); + const hasLabel = label.trim().length > 0; + const status = entry.state?.status; + const displayName = getToolMetadata(toolName).displayName; + + return ( + +
+ {getToolIcon(toolName)} + + {displayName} + + {hasLabel ? ( + status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? ( + renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons) + ) : ( + status === 'error' ? ( + + {label} + + ) : ( + + {label} + + ) + ) + ) : null} +
+
+ ); +}, (prev, next) => { + return prev.isMobile === next.isMobile + && prev.animateTailText === next.animateTailText + && prev.showToolFileIcons === next.showToolFileIcons + && getTaskSummaryEntryRenderSignature(prev.entry) === getTaskSummaryEntryRenderSignature(next.entry); +}); + +TaskSummaryEntryRow.displayName = 'TaskSummaryEntryRow'; + +const TaskSummaryEntriesList = React.memo(({ + entries, + isExpanded, + isMobile, + animateTailText, + showToolFileIcons, +}: { + entries: TaskToolSummaryEntry[]; + isExpanded: boolean; + isMobile: boolean; + animateTailText: boolean; + showToolFileIcons: boolean; +}) => { + const visibleEntries = isExpanded ? entries : entries.slice(-6); + const hiddenCount = Math.max(0, entries.length - visibleEntries.length); + const visibleStartIndex = entries.length - visibleEntries.length; + + return ( + +
+ {hiddenCount > 0 ? ( +
+{hiddenCount} more…
+ ) : null} + + {visibleEntries.map((entry, idx) => { + const absoluteIndex = isExpanded ? idx : visibleStartIndex + idx; + const rowKey = entry.id ?? `${getTaskSummaryEntryRenderSignature(entry)}:${absoluteIndex}`; + return ( + + ); + })} +
+
+ ); +}, (prev, next) => { + return prev.isExpanded === next.isExpanded + && prev.isMobile === next.isMobile + && prev.animateTailText === next.animateTailText + && prev.showToolFileIcons === next.showToolFileIcons + && areTaskSummaryEntriesRenderEqual(prev.entries, next.entries); +}); + +TaskSummaryEntriesList.displayName = 'TaskSummaryEntriesList'; + const stripTaskMetadataFromOutput = (output: string): string => { // Strip only a trailing ... block. return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); @@ -1233,7 +1321,6 @@ const TaskToolSummary: React.FC<{ const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); const runtime = React.useContext(RuntimeAPIContext); - const displayEntries = entries; const trimmedOutput = typeof output === 'string' ? stripTaskMetadataFromOutput(output) @@ -1262,7 +1349,7 @@ const TaskToolSummary: React.FC<{ ? input.subagent_type : 'subagent'; - if (displayEntries.length === 0 && !hasOutput && !sessionId) { + if (entries.length === 0 && !hasOutput && !sessionId) { return (
@@ -1272,9 +1359,6 @@ const TaskToolSummary: React.FC<{ ); } - const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6); - const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length); - return (
- {displayEntries.length > 0 ? ( - -
- {hiddenCount > 0 ? ( -
+{hiddenCount} more…
- ) : null} - - {visibleEntries.map((entry, idx) => { - const normalizedToolName = normalizeToolName(entry.tool); - const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool'; - const label = getTaskSummaryLabel(entry); - const hasLabel = label.trim().length > 0; - const status = entry.state?.status; - - const displayName = getToolMetadata(toolName).displayName; - - return ( - -
- {getToolIcon(toolName)} - - {displayName} - - {hasLabel ? ( - status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? ( - renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons) - ) : ( - status === 'error' ? ( - - {label} - - ) : ( - - {label} - - ) - ) - ) : null} -
-
- ); - })} -
-
+ {entries.length > 0 ? ( + ) : null} {sessionId && ( @@ -1357,7 +1390,7 @@ const TaskToolSummary: React.FC<{ )} {hasOutput ? ( -
0 || sessionId) && 'pt-1')} +
0 || sessionId) && 'pt-1')} >