perf: isolate chat streaming renders and reduce sidebar render cost (#1672)

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.
This commit is contained in:
bashrusakh
2026-06-18 00:43:16 +03:00
committed by GitHub
parent 077a766f94
commit 59ecd86b4b
47 changed files with 3168 additions and 1829 deletions
-540
View File
@@ -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<sessionKey, number>` 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<string>`
— 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<sessionId, Session>` 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<CacheKey, { projection: TurnProjectionResult }>()
```
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<sessionId, ReviewTransferDirection>`
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<sessionId, Session>` 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 `<ChatViewport>`. 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.
-53
View File
@@ -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<projectId, Promise>` 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<path, FileStatus>` and `badgeByDir` `Map<dirPath, { modified, added }>` 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).
@@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; 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 { ChatInput } from './ChatInput';
import { DraftPresetChips } from './DraftPresetChips'; import { DraftPresetChips } from './DraftPresetChips';
@@ -23,14 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { Icon } from "@/components/icon/Icon"; 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 { cn, formatDirectoryName } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import {
collectVisibleSessionIdsForBlockingRequests,
flattenBlockingRequests,
} from './lib/blockingRequests';
// New sync system imports // New sync system imports
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -38,22 +34,21 @@ import { useStreamingStore } from '@/sync/streaming';
import { import {
useSessionMessageCount, useSessionMessageCount,
useSessionMessageRecords, useSessionMessageRecords,
useSessions,
useDirectorySync,
useSyncDirectory, useSyncDirectory,
useDirectorySync,
useSessionStatus, useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
useParentSession,
} from '@/sync/sync-context'; } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync'; import { useSync } from '@/sync/use-sync';
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache'; import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
import { getSessionMaterializationStatus } from '@/sync/materialization'; import { getSessionMaterializationStatus } from '@/sync/materialization';
import { usePlanDetection } from '@/hooks/usePlanDetection'; import { usePlanDetection } from '@/hooks/usePlanDetection';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; 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 IDLE_SESSION_STATUS = { type: 'idle' as const };
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom'; const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
@@ -140,6 +135,7 @@ type ChatViewportProps = {
isDesktopExpandedInput: boolean; isDesktopExpandedInput: boolean;
isMobile: boolean; isMobile: boolean;
stickyUserHeader: boolean; stickyUserHeader: boolean;
directory?: string;
scrollRef: React.RefObject<HTMLDivElement | null>; scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>; messageListRef: React.RefObject<MessageListHandle | null>;
pendingRevealWork: boolean; pendingRevealWork: boolean;
@@ -168,6 +164,7 @@ const ChatViewport = React.memo(({
isDesktopExpandedInput, isDesktopExpandedInput,
isMobile, isMobile,
stickyUserHeader, stickyUserHeader,
directory,
scrollRef, scrollRef,
messageListRef, messageListRef,
pendingRevealWork, pendingRevealWork,
@@ -235,6 +232,7 @@ const ChatViewport = React.memo(({
isLoadingOlder={isLoadingOlder} isLoadingOlder={isLoadingOlder}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
scrollRef={scrollRef} scrollRef={scrollRef}
directory={directory}
/> />
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && ( {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
<div> <div>
@@ -263,6 +261,7 @@ const ChatViewport = React.memo(({
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isDesktopExpandedInput === next.isDesktopExpandedInput
&& prev.isMobile === next.isMobile && prev.isMobile === next.isMobile
&& prev.stickyUserHeader === next.stickyUserHeader && prev.stickyUserHeader === next.stickyUserHeader
&& prev.directory === next.directory
&& prev.scrollRef === next.scrollRef && prev.scrollRef === next.scrollRef
&& prev.messageListRef === next.messageListRef && prev.messageListRef === next.messageListRef
&& prev.pendingRevealWork === next.pendingRevealWork && prev.pendingRevealWork === next.pendingRevealWork
@@ -407,7 +406,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
effectiveSessionDirectory, effectiveSessionDirectory,
); );
// Messages from sync system // 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 sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
const sessionPrefetchInfo = React.useSyncExternalStore( const sessionPrefetchInfo = React.useSyncExternalStore(
React.useCallback( React.useCallback(
@@ -423,55 +425,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
React.useCallback(() => undefined, []), React.useCallback(() => undefined, []),
); );
// Sessions from sync system
const sessions = useSessions(effectiveSessionDirectory);
// Plan detection - watches messages for plan creation and signals store // Plan detection - watches messages for plan creation and signals store
usePlanDetection(currentSessionId ?? '', sessionMessages); usePlanDetection(currentSessionId ?? '', sessionMessages);
// Session status from sync system // Session status from sync system
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '', effectiveSessionDirectory) ?? IDLE_SESSION_STATUS; const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '', effectiveSessionDirectory) ?? IDLE_SESSION_STATUS;
// Permissions & questions from sync system // Scoped blocking requests — only subscribe to permissions/questions for
const allPermissions = useDirectorySync( // the current session + descendant subagent sessions, not all sessions in
React.useCallback((s) => s.permission ?? {}, []), // the directory.
effectiveSessionDirectory, const sessionPermissions = useScopedBlockingPermissions(currentSessionId, effectiveSessionDirectory);
); const sessionQuestions = useScopedBlockingQuestions(currentSessionId, effectiveSessionDirectory);
const allQuestions = useDirectorySync(
React.useCallback((s) => s.question ?? {}, []),
effectiveSessionDirectory,
);
// Convert Record → Map for blockingRequests helpers
const permissionsMap = React.useMemo(() => {
const m = new Map<string, PermissionRequest[]>();
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<string, QuestionRequest[]>();
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(() => { const sessionIsWorking = React.useMemo(() => {
if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) { if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false; return false;
@@ -561,15 +526,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
return project ? getProjectDisplayLabel(project) : null; return project ? getProjectDisplayLabel(project) : null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); }, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
const parentSession = React.useMemo(() => { const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
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 handleReturnToParentSession = React.useCallback(() => { const handleReturnToParentSession = React.useCallback(() => {
if (!parentSession) return; if (!parentSession) return;
@@ -943,6 +900,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
isDesktopExpandedInput={isDesktopExpandedInput} isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile} isMobile={isMobile}
stickyUserHeader={stickyUserHeader} stickyUserHeader={stickyUserHeader}
directory={effectiveSessionDirectory}
scrollRef={scrollRef} scrollRef={scrollRef}
messageListRef={messageListRef} messageListRef={messageListRef}
pendingRevealWork={timelineController.pendingRevealWork} pendingRevealWork={timelineController.pendingRevealWork}
+15 -21
View File
@@ -74,6 +74,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
import { fetchResponseStyleInstruction } from '@/lib/responseStyle'; import { fetchResponseStyleInstruction } from '@/lib/responseStyle';
import { wrapSystemReminder } from '@/lib/systemReminder'; import { wrapSystemReminder } from '@/lib/systemReminder';
import { getSyncMessages } from '@/sync/sync-refs'; 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 { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
import { isSyntheticPart } from '@/lib/messages/synthetic'; import { isSyntheticPart } from '@/lib/messages/synthetic';
import { import {
@@ -89,11 +90,10 @@ import {
buildAttachmentCitationText, buildAttachmentCitationText,
findAttachmentCitationRanges, findAttachmentCitationRanges,
} from './attachmentCitations'; } 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 MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = []; const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_MESSAGES: Message[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/; const FILE_MENTION_TOKEN = /^@[^\s]+$/;
// Single-line URL pasted over a selection becomes a markdown link. // Single-line URL pasted over a selection becomes a markdown link.
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i; const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
@@ -363,34 +363,28 @@ const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.memo(({ se
const [restoringId, setRestoringId] = React.useState<string | null>(null); const [restoringId, setRestoringId] = React.useState<string | null>(null);
const [forkingId, setForkingId] = React.useState<string | null>(null); const [forkingId, setForkingId] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState(true); const [collapsed, setCollapsed] = React.useState(true);
const revertMessageID = useDirectorySync( const revertedStateRef = React.useRef<RevertedMessageDockState>(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
const revertedState = useDirectorySync(
React.useCallback((state) => { React.useCallback((state) => {
if (!sessionId) return undefined; const next = buildRevertedMessageDockState(state, sessionId, revertedStateRef.current);
const session = state.session.find((item) => item.id === sessionId); revertedStateRef.current = next;
return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID; return next;
}, [sessionId]), }, [sessionId]),
directory, directory,
); );
const sessionMessages = useDirectorySync( const revertMessageID = revertedState.revertMessageID;
React.useCallback((state) => (sessionId ? state.message[sessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES), [sessionId]),
directory,
);
const partsByMessage = useDirectorySync(React.useCallback((state) => state.part, []), directory);
const userMessages = React.useMemo( const userMessages = React.useMemo(
() => sessionMessages.filter((message): message is Message & { role: 'user' } => message.role === 'user'), () => revertedState.records.map((record) => record.message),
[sessionMessages], [revertedState],
); );
const noTextContent = t('chat.revertPopover.noTextContent'); const noTextContent = t('chat.revertPopover.noTextContent');
const items = React.useMemo(() => { const items = React.useMemo(() => {
if (!revertMessageID) return []; if (!revertMessageID) return [];
return userMessages return revertedState.records.map((record) => ({
.filter((message) => message.id >= revertMessageID) id: record.message.id,
.map((message) => ({ text: getRevertedPreview(record.parts, noTextContent),
id: message.id, }));
text: getRevertedPreview(partsByMessage[message.id] ?? [], noTextContent), }, [noTextContent, revertMessageID, revertedState]);
}));
}, [noTextContent, partsByMessage, revertMessageID, userMessages]);
const firstRevertedMessageId = items[0]?.id; const firstRevertedMessageId = items[0]?.id;
React.useEffect(() => { React.useEffect(() => {
@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import 'katex/dist/katex.min.css';
import morphdom from 'morphdom'; import morphdom from 'morphdom';
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2'; 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 { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; 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 { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
import { import {
attachMarkdownInteractions, attachMarkdownInteractions,
@@ -1032,10 +1031,16 @@ const useMorphdomMarkdown = ({
// `display:contents` keeps margin-collapsing/spacing identical to a flat // `display:contents` keeps margin-collapsing/spacing identical to a flat
// HTML body — the wrapper exists only for per-block reconciliation. // HTML body — the wrapper exists only for per-block reconciliation.
block.style.display = 'contents'; 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
// <pre>/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); target.appendChild(block);
} }
}, [containerRef, text]); }, [containerRef, text, ctx]);
React.useEffect(() => { React.useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
+25 -96
View File
@@ -6,19 +6,19 @@ import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem'; import TurnItem from './components/TurnItem';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords'; import { useTurnRecords } from './hooks/useTurnRecords';
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { FadeInDisabledProvider } from './message/FadeInOnReveal'; import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug'; import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types'; import type { StreamPhase } from './message/types';
import { normalizeParts } from './message/partUtils';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow'; import { useSessionParts } from '@/sync/sync-context';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata'; import type { ReviewTransferDirection } from '@/lib/reviewFlow';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5; const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = []; const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
@@ -88,13 +88,6 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => {
?? 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 getPartText = (part: Part): string => {
const text = (part as { text?: unknown }).text; const text = (part as { text?: unknown }).text;
if (typeof text === 'string') { if (typeof text === 'string') {
@@ -107,40 +100,6 @@ const getPartText = (part: Part): string => {
return ''; 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<string, unknown>),
clientRole: 'user',
} as unknown as typeof message.info)
: message.info,
parts: changedParts ? nextParts : message.parts,
};
};
const normalizeCompactionSummaryMessage = ( const normalizeCompactionSummaryMessage = (
message: ChatMessageEntry, message: ChatMessageEntry,
compactionCommandIds: Set<string>, compactionCommandIds: Set<string>,
@@ -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<ChatMessageEntry, ChatMessageEntry>();
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 { interface MessageListProps {
sessionKey: string; sessionKey: string;
disableStaging?: boolean; disableStaging?: boolean;
@@ -442,6 +368,7 @@ interface MessageListProps {
isLoadingOlder: boolean; isLoadingOlder: boolean;
scrollToBottom?: () => void; scrollToBottom?: () => void;
scrollRef?: React.RefObject<HTMLDivElement | null>; scrollRef?: React.RefObject<HTMLDivElement | null>;
directory?: string;
} }
export interface MessageListHandle { export interface MessageListHandle {
@@ -1007,11 +934,10 @@ type StaticHistoryListProps = {
chatRenderMode: 'sorted' | 'live'; chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void; onUserAnimationConsumed: (messageId: string) => void;
activeStreamingPhase?: StreamPhase | null;
reviewTransferDirection?: ReviewTransferDirection | 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) => { const renderEntry = React.useCallback((entry: RenderEntry) => {
return ( return (
<MessageListEntry <MessageListEntry
@@ -1029,11 +955,11 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s
shouldAnimateUserMessage={shouldAnimateUserMessage} shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed} onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={null} activeStreamingMessageId={null}
activeStreamingPhase={activeStreamingPhase} activeStreamingPhase={null}
reviewTransferDirection={reviewTransferDirection} reviewTransferDirection={reviewTransferDirection}
/> />
); );
}, [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) { if (!shouldVirtualize) {
return ( return (
@@ -1074,6 +1000,7 @@ StaticHistoryList.displayName = 'StaticHistoryList';
const StreamingTailContent: React.FC<{ const StreamingTailContent: React.FC<{
entry: RenderEntry; entry: RenderEntry;
directory?: string;
onMessageContentChange: (reason?: ContentChangeReason) => void; onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers; getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void; scrollToBottom?: () => void;
@@ -1083,6 +1010,7 @@ const StreamingTailContent: React.FC<{
turnUiStates: Map<string, TurnUiState>; turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void; onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live'; chatRenderMode: 'sorted' | 'live';
showTurnChangedFiles: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void; onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null; activeStreamingMessageId?: string | null;
@@ -1090,6 +1018,7 @@ const StreamingTailContent: React.FC<{
reviewTransferDirection?: ReviewTransferDirection | null; reviewTransferDirection?: ReviewTransferDirection | null;
}> = ({ }> = ({
entry, entry,
directory,
onMessageContentChange, onMessageContentChange,
getAnimationHandlers, getAnimationHandlers,
scrollToBottom, scrollToBottom,
@@ -1099,15 +1028,24 @@ const StreamingTailContent: React.FC<{
turnUiStates, turnUiStates,
onToggleTurnGroup, onToggleTurnGroup,
chatRenderMode, chatRenderMode,
showTurnChangedFiles,
shouldAnimateUserMessage, shouldAnimateUserMessage,
onUserAnimationConsumed, onUserAnimationConsumed,
activeStreamingMessageId, activeStreamingMessageId,
activeStreamingPhase, activeStreamingPhase,
reviewTransferDirection, reviewTransferDirection,
}) => { }) => {
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
activeStreamingMessageId,
liveParts,
showTextJustificationActivity: chatRenderMode === 'sorted',
showTurnChangedFiles,
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
return ( return (
<MessageListEntry <MessageListEntry
entry={entry} entry={liveEntry}
onMessageContentChange={onMessageContentChange} onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers} getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
@@ -1141,6 +1079,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
isLoadingOlder, isLoadingOlder,
scrollToBottom, scrollToBottom,
scrollRef, scrollRef,
directory,
}, ref) => { }, ref) => {
streamPerfCount('ui.message_list.render'); streamPerfCount('ui.message_list.render');
const stickyUserHeader = useUIStore(state => state.stickyUserHeader); const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
@@ -1149,18 +1088,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const showTurnChangedFiles = useUIStore((state) => state.showTurnChangedFiles); const showTurnChangedFiles = useUIStore((state) => state.showTurnChangedFiles);
const defaultActivityExpanded = activityRenderMode === 'summary'; const defaultActivityExpanded = activityRenderMode === 'summary';
const reviewTransferDirection = useGlobalSessionsStore((state) => { const reviewTransferDirection = useGlobalSessionsStore((state) => {
const currentSession = state.activeSessions.find((session) => session.id === sessionKey); return state.reviewTransferBySessionId.get(sessionKey) ?? null;
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;
}); });
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(() => new Map()); const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(() => new Map());
const userAnimationRef = React.useRef<{ const userAnimationRef = React.useRef<{
@@ -1665,12 +1593,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage} shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed} onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection} reviewTransferDirection={reviewTransferDirection}
/> />
{trailingStreamingEntry ? ( {trailingStreamingEntry ? (
<StreamingTailContent <StreamingTailContent
entry={trailingStreamingEntry} entry={trailingStreamingEntry}
directory={directory}
onMessageContentChange={stableTailContentChange} onMessageContentChange={stableTailContentChange}
getAnimationHandlers={stableGetAnimationHandlers} getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom} scrollToBottom={stableScrollToBottom}
@@ -1680,6 +1608,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
turnUiStates={turnUiStates} turnUiStates={turnUiStates}
onToggleTurnGroup={toggleTurnGroup} onToggleTurnGroup={toggleTurnGroup}
chatRenderMode={chatRenderMode} chatRenderMode={chatRenderMode}
showTurnChangedFiles={showTurnChangedFiles}
shouldAnimateUserMessage={shouldAnimateUserMessage} shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed} onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId} activeStreamingMessageId={activeStreamingMessageId}
@@ -334,24 +334,67 @@ export const useChatTimelineController = ({
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false; return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
}, [messageListRef]); }, [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(() => { React.useLayoutEffect(() => {
const snap = prePrependScrollRef.current;
const container = scrollRef.current; const container = scrollRef.current;
if (!snap || !container) return; if (!container) return;
prePrependScrollRef.current = null;
// When a viewport anchor is available, delegate to MessageList const snap = prePrependScrollRef.current;
// restoreViewportAnchor which falls back to virtualizer-aware if (snap) {
// scrollHistoryIndexIntoView when the element is not in the DOM. prePrependScrollRef.current = null;
if (snap.anchor && restoreViewportAnchor(snap.anchor)) { // When a viewport anchor is available, delegate to MessageList
return; // 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 prependTrackingRef.current = {
const delta = container.scrollHeight - snap.height; oldestId: renderedMessages[0]?.info?.id ?? null,
if (delta > 0) { newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null,
container.scrollTop = snap.top + delta; scrollHeight: container.scrollHeight,
} };
}, [renderedMessages, scrollRef, restoreViewportAnchor]); }, [renderedMessages, scrollRef, restoreViewportAnchor]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []); const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords'; import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types'; import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
import { buildProjectionCacheKey, getCachedProjection, setCachedProjection } from '../lib/turns/turnProjectionCache';
import { streamPerfMeasure } from '@/stores/utils/streamDebug'; import { streamPerfMeasure } from '@/stores/utils/streamDebug';
interface UseTurnRecordsOptions { interface UseTurnRecordsOptions {
@@ -46,6 +47,18 @@ export const useTurnRecords = (
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]); }, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]);
const projection = React.useMemo(() => { 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', () => { return streamPerfMeasure('ui.turns.projection_ms', () => {
const nextProjection = projectTurnRecords(messages, { const nextProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current, previousProjection: previousProjectionRef.current,
@@ -53,9 +66,18 @@ export const useTurnRecords = (
showTurnChangedFiles: options.showTurnChangedFiles, showTurnChangedFiles: options.showTurnChangedFiles,
}); });
previousProjectionRef.current = nextProjection; previousProjectionRef.current = nextProjection;
const cacheKey = buildProjectionCacheKey(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
);
setCachedProjection(cacheKey, nextProjection);
return nextProjection; return nextProjection;
}); });
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles]); }, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey]);
const staticTurns = React.useMemo(() => { const staticTurns = React.useMemo(() => {
const nextStatic = projection.turns.length <= 1 const nextStatic = projection.turns.length <= 1
@@ -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<string, unknown>),
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<ChatMessageEntry, ChatMessageEntry>();
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;
};
@@ -106,6 +106,27 @@ describe('projectTurnRecords', () => {
expect(next.turns[1]).not.toBe(initial.turns[1]); 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', () => { test('reuses the whole turns array when every turn is unchanged', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 }); const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 }); const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
@@ -115,12 +115,43 @@ const canReusePreviousTurn = (previous: TurnRecord, next: TurnRecord): boolean =
&& areSameMessageRefs(previous.assistantMessages, next.assistantMessages); && 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[], turns: TurnRecord[],
previousProjection?: TurnProjectionResult | null, effectiveOptions: ProjectTurnRecordsOptions,
): TurnRecord[] => { ): TurnRecord[] => {
const previousProjection = effectiveOptions.previousProjection;
if (!previousProjection || previousProjection.turns.length === 0 || turns.length === 0) { 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; let canReuseTurnArray = previousProjection.turns.length === turns.length;
@@ -137,14 +168,14 @@ const stabilizeTurnRecords = (
} }
canReuseTurnArray = false; canReuseTurnArray = false;
return turn; return hydrateTurnRecord(turn, effectiveOptions);
}); });
if (canReuseTurnArray && reusedAnyTurn) { if (canReuseTurnArray && reusedAnyTurn) {
return previousProjection.turns; return previousProjection.turns;
} }
return reusedAnyTurn ? nextTurns : turns; return nextTurns;
}; };
export const projectTurnRecords = ( export const projectTurnRecords = (
@@ -214,33 +245,7 @@ export const projectTurnRecords = (
groupedMessageIds.add(message.info.id); groupedMessageIds.add(message.info.id);
}); });
turns.forEach((turn) => { const stableTurns = hydrateStableTurnRecords(turns, effectiveOptions);
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 projection = projectTurnIndexes(stableTurns); const projection = projectTurnIndexes(stableTurns);
const ungroupedMessageIds = new Set<string>(); const ungroupedMessageIds = new Set<string>();
messages.forEach((message) => { messages.forEach((message) => {
@@ -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]);
});
});
@@ -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 = <TEntry extends StreamingTailEntry>(
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,
};
};
@@ -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);
});
});
@@ -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<string, TurnProjectionResult>();
const objectVersionByRef = new WeakMap<object, number>();
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);
};
@@ -88,7 +88,11 @@ const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void =>
// Already wrapped (idempotent across morphdom passes). // Already wrapped (idempotent across morphdom passes).
if (parent.closest('[data-component="markdown-code"]')) continue; 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'); const wrapper = document.createElement('div');
wrapper.setAttribute('data-component', 'markdown-code'); wrapper.setAttribute('data-component', 'markdown-code');
@@ -306,16 +306,6 @@ const sanitize = (html: string): string => {
return DOMPurify.sanitize(html, SANITIZE_CONFIG) as unknown as string; return DOMPurify.sanitize(html, SANITIZE_CONFIG) as unknown as string;
}; };
const escapeHtml = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
export const fallbackHtml = (markdown: string): string =>
escapeHtml(markdown).replace(/\r\n?/g, '\n').replace(/\n/g, '<br>');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache) // Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
@@ -349,6 +339,22 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
return sanitize(highlighted); 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 = { export type RenderedBlock = {
// Stable identity across renders for per-block DOM reconciliation. Encodes // Stable identity across renders for per-block DOM reconciliation. Encodes
// content + mode + highlight so any change forces that block (and only that // content + mode + highlight so any change forces that block (and only that
@@ -12,11 +12,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectorySync, useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; import { useDirectorySync, useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
import { getSyncChildStores } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore'; 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 { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Button } from '@/components/ui/button'; 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 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 TASK_TOOL_FALLBACK_RETRY_MS = 3000;
const GIT_REFRESH_MUTATING_TOOLS = new Set([ const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash', 'bash',
@@ -1025,41 +1010,6 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
return entries; 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<string, unknown> | 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<string, unknown>).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 getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
const title = entry.state?.title; const title = entry.state?.title;
if (typeof title === 'string' && title.trim().length > 0) { 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); 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 (
<ToolRevealOnMount animate={animateTailText} wipe>
<div className={cn('flex gap-2 min-w-0 w-full', isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span
className="typography-meta text-foreground/80 flex-shrink-0"
style={{ color: 'var(--tools-title)' }}
title={displayName}
>
{displayName}
</span>
{hasLabel ? (
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
) : (
status === 'error' ? (
<span className={cn(
'typography-meta flex-1 min-w-0 text-[var(--status-error)]',
isMobile ? 'whitespace-normal break-words' : 'truncate',
)}>
{label}
</span>
) : (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className={cn(
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
isMobile ? 'whitespace-normal break-words' : 'truncate',
)}
style={{ color: 'var(--tools-description)' }}
title={label}
>
{label}
</Text>
)
)
) : null}
</div>
</ToolRevealOnMount>
);
}, (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 (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more…</div>
) : null}
{visibleEntries.map((entry, idx) => {
const absoluteIndex = isExpanded ? idx : visibleStartIndex + idx;
const rowKey = entry.id ?? `${getTaskSummaryEntryRenderSignature(entry)}:${absoluteIndex}`;
return (
<TaskSummaryEntryRow
key={rowKey}
entry={entry}
isMobile={isMobile}
animateTailText={animateTailText}
showToolFileIcons={showToolFileIcons}
/>
);
})}
</div>
</ToolScrollableSection>
);
}, (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 => { const stripTaskMetadataFromOutput = (output: string): string => {
// Strip only a trailing <task_metadata>...</task_metadata> block. // Strip only a trailing <task_metadata>...</task_metadata> block.
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
@@ -1233,7 +1321,6 @@ const TaskToolSummary: React.FC<{
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const runtime = React.useContext(RuntimeAPIContext); const runtime = React.useContext(RuntimeAPIContext);
const displayEntries = entries;
const trimmedOutput = typeof output === 'string' const trimmedOutput = typeof output === 'string'
? stripTaskMetadataFromOutput(output) ? stripTaskMetadataFromOutput(output)
@@ -1262,7 +1349,7 @@ const TaskToolSummary: React.FC<{
? input.subagent_type ? input.subagent_type
: 'subagent'; : 'subagent';
if (displayEntries.length === 0 && !hasOutput && !sessionId) { if (entries.length === 0 && !hasOutput && !sessionId) {
return ( return (
<div className="relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]"> <div className="relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]">
<div className="typography-meta text-muted-foreground/70"> <div className="typography-meta text-muted-foreground/70">
@@ -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 ( return (
<div <div
className={cn( className={cn(
@@ -1283,65 +1367,14 @@ const TaskToolSummary: React.FC<{
'before:top-[-0.25rem] before:bottom-0' 'before:top-[-0.25rem] before:bottom-0'
)} )}
> >
{displayEntries.length > 0 ? ( {entries.length > 0 ? (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal> <TaskSummaryEntriesList
<div className="w-full min-w-0 space-y-1"> entries={entries}
{hiddenCount > 0 ? ( isExpanded={isExpanded}
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more…</div> isMobile={isMobile}
) : null} animateTailText={animateTailText}
showToolFileIcons={showToolFileIcons}
{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 (
<ToolRevealOnMount key={entry.id ?? `${toolName}-${idx}`} animate={animateTailText} wipe>
<div className={cn("flex gap-2 min-w-0 w-full", isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span
className="typography-meta text-foreground/80 flex-shrink-0"
style={{ color: 'var(--tools-title)' }}
title={displayName}
>
{displayName}
</span>
{hasLabel ? (
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
) : (
status === 'error' ? (
<span className={cn(
'typography-meta flex-1 min-w-0 text-[var(--status-error)]',
isMobile ? 'whitespace-normal break-words' : 'truncate'
)}>
{label}
</span>
) : (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className={cn(
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
isMobile ? 'whitespace-normal break-words' : 'truncate'
)}
style={{ color: 'var(--tools-description)' }}
title={label}
>
{label}
</Text>
)
)
) : null}
</div>
</ToolRevealOnMount>
);
})}
</div>
</ToolScrollableSection>
) : null} ) : null}
{sessionId && ( {sessionId && (
@@ -1357,7 +1390,7 @@ const TaskToolSummary: React.FC<{
)} )}
{hasOutput ? ( {hasOutput ? (
<div className={cn('space-y-1', (displayEntries.length > 0 || sessionId) && 'pt-1')} <div className={cn('space-y-1', (entries.length > 0 || sessionId) && 'pt-1')}
> >
<button <button
type="button" type="button"
@@ -2147,12 +2180,11 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
isTaskTool, isTaskTool,
parentSessionId: currentSessionId ?? undefined, parentSessionId: currentSessionId ?? undefined,
taskStartTime: taskSessionResolutionStart, taskStartTime: taskSessionResolutionStart,
isTaskFinalized: isFinalized,
sessions: storeState.session, sessions: storeState.session,
sessionStatusMap: storeState.session_status, sessionStatusMap: storeState.session_status,
hasRetried: taskFallbackRetried, hasRetried: taskFallbackRetried,
}); });
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized, taskFallbackRetried]), }, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, taskFallbackRetried]),
currentDirectory, currentDirectory,
); );
@@ -2172,49 +2204,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return buildTaskSummaryEntriesFromSession(childSessionMessages); return buildTaskSummaryEntriesFromSession(childSessionMessages);
}, [childSessionMessages, isTaskTool, taskSessionId]); }, [childSessionMessages, isTaskTool, taskSessionId]);
const childSessionHasInFlightTools = React.useMemo(() => {
if (!isTaskTool || !taskSessionId || !Array.isArray(childSessionMessages) || childSessionMessages.length === 0) {
return false;
}
for (const message of childSessionMessages) {
if (message?.info?.role !== 'assistant') {
continue;
}
const parts = Array.isArray(message.parts) ? message.parts : [];
for (const childPart of parts) {
if (childPart?.type !== 'tool') {
continue;
}
const childStatus =
typeof childPart === 'object' && childPart !== null && 'state' in childPart
? (childPart.state as { status?: string } | undefined)?.status
: undefined;
if (childStatus === 'running' || childStatus === 'pending' || childStatus === 'started') {
return true;
}
}
}
return false;
}, [childSessionMessages, isTaskTool, taskSessionId]);
const childSessionActivity = useSessionActivity(taskSessionId, currentDirectory);
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
const [taskPendingFinalFetch, setTaskPendingFinalFetch] = React.useState(false);
const taskPollNoChangeCountRef = React.useRef(0);
const taskPollLastSignatureRef = React.useRef<string>('');
const taskFinalFetchDoneRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
setTaskChildSeenActive(false);
setTaskChildPollingStopped(false);
setTaskPendingFinalFetch(false);
taskPollNoChangeCountRef.current = 0;
taskPollLastSignatureRef.current = '';
taskFinalFetchDoneRef.current = false;
setTaskFallbackRetried(false); setTaskFallbackRetried(false);
}, [taskSessionId]); }, [taskSessionId]);
@@ -2251,148 +2241,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
taskSessionResolutionStart, taskSessionResolutionStart,
]); ]);
React.useEffect(() => {
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId) {
return;
}
const childSessionIsActive =
childSessionActivity.phase === 'busy'
|| childSessionActivity.phase === 'retry'
|| childSessionHasInFlightTools;
if (childSessionIsActive) {
if (!taskChildSeenActive) {
setTaskChildSeenActive(true);
}
if (taskChildPollingStopped) {
setTaskChildPollingStopped(false);
}
if (taskPendingFinalFetch) {
setTaskPendingFinalFetch(false);
}
return;
}
// Always stop polling if already done.
if (taskChildPollingStopped && taskFinalFetchDoneRef.current) {
return;
}
// Normal settle path: child went idle after we saw it active, and we have entries.
// Schedule a grace period before marking polling as stopped.
if (taskChildSeenActive && childSessionTaskSummaryEntries.length > 0 && !taskChildPollingStopped) {
if (typeof window === 'undefined') {
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}
// Final-fetch path: child went idle before parent saw it active, or we have no
// entries yet. First stop polling after the settle grace period. A separate
// effect performs the final fetch once polling has fully stopped, avoiding
// races with any in-flight polling response.
if (!taskChildPollingStopped && !taskFinalFetchDoneRef.current) {
if (typeof window === 'undefined') {
setTaskPendingFinalFetch(true);
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskPendingFinalFetch(true);
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
hasFinalMetadataTaskSummary,
activeLatched,
isFinalized,
isTaskTool,
taskPendingFinalFetch,
taskChildPollingStopped,
taskChildSeenActive,
taskSessionId,
]);
React.useEffect(() => {
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) {
return;
}
let cancelled = false;
const capturedSessionId = taskSessionId;
const runFinalFetch = async () => {
try {
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({
sessionID: capturedSessionId,
limit: isVSCodeRuntime() ? VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT : TASK_TOOL_INITIAL_FETCH_LIMIT,
});
if (cancelled) {
return;
}
const messages = response.data ?? [];
if (Array.isArray(messages) && messages.length > 0) {
const childStores = getSyncChildStores();
childStores.update(currentDirectory, (prev) => {
const records = messages as SessionMessageWithParts[];
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
for (const rec of records) {
partPatch[rec.info.id] = rec.parts;
}
return {
message: { ...prev.message, [capturedSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
part: partPatch,
};
});
}
taskFinalFetchDoneRef.current = true;
setTaskPendingFinalFetch(false);
} catch {
if (cancelled) {
return;
}
setTaskPendingFinalFetch(false);
setTaskChildPollingStopped(false);
}
};
void runFinalFetch();
return () => {
cancelled = true;
};
}, [
currentDirectory,
hasFinalMetadataTaskSummary,
isTaskTool,
taskChildPollingStopped,
taskPendingFinalFetch,
taskSessionId,
]);
React.useEffect(() => { React.useEffect(() => {
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') { if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
setLocalFinalizedAt(undefined); setLocalFinalizedAt(undefined);
@@ -2425,147 +2273,25 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
} }
return metadataTaskSummaryEntries; return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]); }, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
const taskSummaryRenderSignature = React.useMemo(() => {
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
}, [taskSummaryEntries]);
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId) {
return;
}
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
if (hasFinalMetadataTaskSummary) {
return;
}
const shouldPoll =
!taskChildPollingStopped
&& (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
const shouldFetchSnapshot = !taskPendingFinalFetch && (childSessionTaskSummaryEntries.length === 0 || shouldPoll);
if (!shouldFetchSnapshot) {
return;
}
let cancelled = false;
let pollTimer: number | undefined;
const isVisible = () => {
if (typeof document === 'undefined') {
return true;
}
return document.visibilityState === 'visible';
};
const resolveFetchLimit = (isInitialFetch: boolean) => {
if (isVSCodeRuntime()) {
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT;
}
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return TASK_TOOL_IDLE_FETCH_LIMIT;
};
const resolvePollDelay = () => {
if (!isVisible()) {
return TASK_TOOL_POLL_HIDDEN_MS;
}
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
return TASK_TOOL_POLL_IDLE_MS;
}
return TASK_TOOL_POLL_FAST_MS;
};
const scheduleNextPoll = () => {
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
return;
}
pollTimer = window.setTimeout(() => {
pollTimer = undefined;
void fetchSessionMessages(false);
}, resolvePollDelay());
};
const fetchSessionMessages = async (isInitialFetch: boolean) => {
try {
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({
sessionID: taskSessionId,
limit: resolveFetchLimit(isInitialFetch),
});
const messages = response.data ?? [];
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
return;
}
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
if (nextSignature === taskPollLastSignatureRef.current) {
taskPollNoChangeCountRef.current += 1;
return;
}
taskPollLastSignatureRef.current = nextSignature;
taskPollNoChangeCountRef.current = 0;
// Inject fetched subagent messages into sync child store
const childStores = getSyncChildStores();
childStores.update(currentDirectory, (prev) => {
const records = messages as SessionMessageWithParts[];
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
for (const rec of records) {
partPatch[rec.info.id] = rec.parts;
}
return {
message: { ...prev.message, [taskSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
part: partPatch,
};
});
} catch {
// Ignore transient subagent fetch errors.
} finally {
scheduleNextPoll();
}
};
void fetchSessionMessages(true);
return () => {
cancelled = true;
if (typeof pollTimer === 'number') {
window.clearTimeout(pollTimer);
}
};
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
hasFinalMetadataTaskSummary,
isActive,
isTaskTool,
taskPendingFinalFetch,
taskChildPollingStopped,
taskSessionId,
]);
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
React.useEffect(() => { React.useEffect(() => {
if (!isTaskTool) { if (!isTaskTool) {
lastTaskSummaryRenderSignatureRef.current = null;
return; return;
} }
if (taskSummaryLenRef.current === taskSummaryEntries.length) {
const previous = lastTaskSummaryRenderSignatureRef.current;
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
return; return;
} }
taskSummaryLenRef.current = taskSummaryEntries.length;
onContentChange?.('structural'); onContentChangeRef.current?.('structural');
}, [isTaskTool, onContentChange, taskSummaryEntries.length]); }, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
const diffStats = React.useMemo(() => { const diffStats = React.useMemo(() => {
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch') return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
@@ -31,13 +31,16 @@ describe('resolveFallbackTaskSessionId', () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('returns undefined when task is finalized', () => { it('returns undefined when multiple idle candidates are ambiguous', () => {
const result = resolveFallbackTaskSessionId({ const result = resolveFallbackTaskSessionId({
isTaskTool: true, isTaskTool: true,
parentSessionId, parentSessionId,
taskStartTime, taskStartTime,
sessions: [], sessions: [
isTaskFinalized: true, makeSession({ id: 'child-a', parentID: parentSessionId, time: { created: taskStartTime + 100 } }),
makeSession({ id: 'child-b', parentID: parentSessionId, time: { created: taskStartTime + 200 } }),
],
sessionStatusMap: {},
}); });
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
@@ -29,8 +29,6 @@ export interface ResolveFallbackParams {
parentSessionId: string | undefined; parentSessionId: string | undefined;
/** When the task tool started (ms timestamp) */ /** When the task tool started (ms timestamp) */
taskStartTime: number | undefined; taskStartTime: number | undefined;
/** True when the task tool is finalized (completed/error/etc.) */
isTaskFinalized?: boolean;
/** Sessions from the directory store */ /** Sessions from the directory store */
sessions: Session[]; sessions: Session[];
/** Session status map from the sync store */ /** Session status map from the sync store */
@@ -45,8 +43,8 @@ export interface ResolveFallbackParams {
* *
* Returns `undefined` when: * Returns `undefined` when:
* - Not a task tool * - Not a task tool
* - Task is finalized
* - Parent session is unknown * - Parent session is unknown
* - Task start time is unknown
* - No unambiguous match found * - No unambiguous match found
*/ */
export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined { export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined {
@@ -54,7 +52,6 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
isTaskTool, isTaskTool,
parentSessionId, parentSessionId,
taskStartTime, taskStartTime,
isTaskFinalized = false,
sessions, sessions,
sessionStatusMap, sessionStatusMap,
hasRetried = false, hasRetried = false,
@@ -64,6 +61,10 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return undefined; return undefined;
} }
if (typeof taskStartTime !== 'number') {
return undefined;
}
// Filter candidate sessions: parentID matches the current session. // Filter candidate sessions: parentID matches the current session.
let candidates = sessions.filter((session) => { let candidates = sessions.filter((session) => {
if (!session?.id || session.parentID !== parentSessionId) { if (!session?.id || session.parentID !== parentSessionId) {
@@ -72,19 +73,14 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return true; return true;
}); });
// When the task is still running, apply no time window — late-appearing // Apply the time window even while running. Without it, a newly rendered task
// child sessions should still match. Once finalized, restrict to sessions // can briefly bind to the previous child session before its own child exists.
// created within a generous window around the task start to avoid binding const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
// to stale siblings. If taskStartTime is unavailable (cross-OpenCode const latestAllowed = taskStartTime + windowMs;
// sessions), skip the time filter entirely. candidates = candidates.filter((session) => {
if (typeof taskStartTime === 'number' && isTaskFinalized) { const created = session.time?.created;
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS; return typeof created === 'number' && created >= taskStartTime && created <= latestAllowed;
const latestAllowed = taskStartTime + windowMs; });
candidates = candidates.filter((session) => {
const created = session.time?.created;
return typeof created === 'number' && created >= taskStartTime - 2_000 && created <= latestAllowed;
});
}
if (candidates.length === 0) { if (candidates.length === 0) {
return undefined; return undefined;
@@ -105,18 +101,6 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return liveCandidates[0].id; return liveCandidates[0].id;
} }
// All idle: pick the most recently created child session.
// This handles the common case where a delegation completed and the
// user is viewing the task tool result inline.
if (liveCandidates.length === 0 && candidates.length > 1) {
const sorted = [...candidates].sort((a, b) => {
const aCreated = typeof a.time?.created === 'number' ? a.time.created : 0;
const bCreated = typeof b.time?.created === 'number' ? b.time.created : 0;
return bCreated - aCreated;
});
return sorted[0].id;
}
// Ambiguous — do not guess // Ambiguous — do not guess
return undefined; return undefined;
} }
@@ -0,0 +1,89 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from '@/sync/types';
import { EMPTY_REVERTED_MESSAGE_DOCK_STATE, buildRevertedMessageDockState } from './revertedMessageDockState';
const message = (id: string, role: 'user' | 'assistant'): Message => ({
id,
role,
sessionID: 'ses_1',
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): Pick<State, 'session' | 'message' | 'part'> => ({
session: [],
message: {},
part: {},
...partial,
});
describe('buildRevertedMessageDockState', () => {
test('returns a shared empty state when the session is not reverted', () => {
const first = buildRevertedMessageDockState(state({}), 'ses_1');
const second = buildRevertedMessageDockState(
state({ part: { assistant_1: [textPart('part_1', 'streaming')] } }),
'ses_1',
first,
);
expect(first).toBe(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
expect(second).toBe(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
});
test('reuses the previous state when unrelated assistant parts change', () => {
const user = message('user_1', 'user');
const userParts = [textPart('part_user', 'hello')];
const first = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user, message('assistant_1', 'assistant')] },
part: { user_1: userParts, assistant_1: [textPart('part_a', 'a')] },
}),
'ses_1',
);
const second = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user, message('assistant_1', 'assistant')] },
part: { user_1: userParts, assistant_1: [textPart('part_a2', 'updated')] },
}),
'ses_1',
first,
);
expect(second).toBe(first);
});
test('updates when a reverted user message part changes', () => {
const user = message('user_1', 'user');
const first = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user] },
part: { user_1: [textPart('part_user', 'hello')] },
}),
'ses_1',
);
const second = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user] },
part: { user_1: [textPart('part_user_updated', 'updated')] },
}),
'ses_1',
first,
);
expect(second).not.toBe(first);
expect(second.records).toHaveLength(1);
});
});
@@ -0,0 +1,73 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from '@/sync/types';
export type RevertedMessageRecord = {
message: Message & { role: 'user' };
parts: Part[];
};
export type RevertedMessageDockState = {
revertMessageID?: string;
records: RevertedMessageRecord[];
};
const EMPTY_PARTS: Part[] = [];
const EMPTY_REVERTED_RECORDS: RevertedMessageRecord[] = [];
export const EMPTY_REVERTED_MESSAGE_DOCK_STATE: RevertedMessageDockState = {
revertMessageID: undefined,
records: EMPTY_REVERTED_RECORDS,
};
const isUserMessage = (message: Message): message is Message & { role: 'user' } => {
return message.role === 'user';
};
const areRecordsEqual = (left: RevertedMessageRecord[], right: RevertedMessageRecord[]): boolean => {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) {
return false;
}
}
return true;
};
export const buildRevertedMessageDockState = (
state: Pick<State, 'session' | 'message' | 'part'>,
sessionId: string | null,
previous: RevertedMessageDockState = EMPTY_REVERTED_MESSAGE_DOCK_STATE,
): RevertedMessageDockState => {
if (!sessionId) {
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
}
const session = state.session.find((item) => item.id === sessionId);
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
if (!revertMessageID) {
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
}
const messages = state.message[sessionId] ?? [];
const records: RevertedMessageRecord[] = [];
for (const message of messages) {
if (!isUserMessage(message) || message.id < revertMessageID) {
continue;
}
records.push({
message,
parts: state.part[message.id] ?? EMPTY_PARTS,
});
}
const next = records.length === 0 ? EMPTY_REVERTED_RECORDS : records;
if (previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, next)) {
return previous;
}
return {
revertMessageID,
records: next,
};
};
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
interface SessionFolderItemProps<TSessionNode> { interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder; folder: SessionFolder;
@@ -19,7 +20,17 @@ interface SessionFolderItemProps<TSessionNode> {
groupDir?: string | null, groupDir?: string | null,
projectId?: string | null, projectId?: string | null,
archivedBucket?: boolean, archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeChildRenderExtras,
) => React.ReactNode; ) => React.ReactNode;
/**
* Returns the precomputed per-row render extras for a given node. The
* group precomputes subtree-contains lookups once, then resolves a
* per-node structure key here so SessionNodeItem's React.memo comparator
* can answer with a single string compare instead of a recursive walk.
*/
getRenderExtras?: (node: TSessionNode) => SessionNodeRenderExtras<TSessionNode> | undefined;
groupDirectory?: string | null; groupDirectory?: string | null;
projectId?: string | null; projectId?: string | null;
mobileVariant?: boolean; mobileVariant?: boolean;
@@ -54,6 +65,7 @@ const SessionFolderItemBase = <TSessionNode,>({
onRename, onRename,
onDelete, onDelete,
renderSessionNode, renderSessionNode,
getRenderExtras,
groupDirectory, groupDirectory,
projectId, projectId,
mobileVariant = false, mobileVariant = false,
@@ -320,7 +332,7 @@ const SessionFolderItemBase = <TSessionNode,>({
{/* Then sessions */} {/* Then sessions */}
{sessions.length > 0 ? ( {sessions.length > 0 ? (
sessions.map((node) => sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket), renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
) )
) : !subFolderItems ? ( ) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70"> <div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
@@ -41,9 +41,11 @@ import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
import { SidebarFooter } from './sidebar/SidebarFooter'; import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList'; import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem'; import { SessionNodeItem } from './sidebar/SessionNodeItem';
import type { SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
import { useUpdateStore } from '@/stores/useUpdateStore'; import { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
import type { WorktreeMetadata } from '@/types/worktree'; import type { WorktreeMetadata } from '@/types/worktree';
import type { SortableDragHandleProps } from './sidebar/sortableItems'; import type { SortableDragHandleProps } from './sidebar/sortableItems';
import { import {
@@ -55,7 +57,7 @@ import {
type DeleteSessionConfirmState, type DeleteSessionConfirmState,
} from './sidebar/ConfirmDialogs'; } from './sidebar/ConfirmDialogs';
import { BulkActionBar } from './sidebar/BulkActionBar'; import { BulkActionBar } from './sidebar/BulkActionBar';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'; import { useSidebarBulkActions } from './sidebar/hooks/useSidebarBulkActions';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { type SessionGroup, type SessionNode } from './sidebar/types'; import { type SessionGroup, type SessionNode } from './sidebar/types';
import { import {
@@ -153,6 +155,14 @@ const isKnownActiveSessionDirectory = (
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000; const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
const EMPTY_SUBTREE_SET: Set<string> = new Set();
const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...args: Args) => Return): ((...args: Args) => Return) => {
const handlerRef = React.useRef(handler);
handlerRef.current = handler;
return React.useCallback((...args: Args) => handlerRef.current(...args), []);
};
interface SessionSidebarProps { interface SessionSidebarProps {
mobileVariant?: boolean; mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void; onSessionSelected?: (sessionId: string) => void;
@@ -383,6 +393,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
syncSessionsSnapshotRef.current = liveSessions; syncSessionsSnapshotRef.current = liveSessions;
}, [syncSessionStructureSignature, liveSessions]); }, [syncSessionStructureSignature, liveSessions]);
// Batched live-session index. Building this here turns the per-row
// `useSession(session.id)` reads in SessionNodeItem (each of which
// iterates all child-stores via `findLiveSession`) into a single
// Map lookup. With M visible rows, that changes an O(M × child-stores)
// work to O(child-stores) once per Sidebar render.
const liveSessionById = React.useMemo(
() => new Map(liveSessions.map((session) => [session.id, session] as const)),
[liveSessions],
);
const projectWorktreeDiscoveryKey = React.useMemo( const projectWorktreeDiscoveryKey = React.useMemo(
() => projects () => projects
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`) .map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
@@ -399,6 +419,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
void refreshGlobalSessions(syncSessionsSnapshotRef.current); void refreshGlobalSessions(syncSessionsSnapshotRef.current);
}, []); }, []);
// Tracks the last project list we already kicked off discovery for.
// A re-mount with the same project set shouldn't fan out another
// burst of `checkIsGitRepository` / `listProjectWorktrees` calls.
const discoveredProjectsRef = React.useRef<string>('');
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -409,24 +433,39 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const worktreesByProject = new Map<string, WorktreeMetadata[]>(); const worktreesByProject = new Map<string, WorktreeMetadata[]>();
const allWorktrees: WorktreeMetadata[] = []; const allWorktrees: WorktreeMetadata[] = [];
await Promise.all( // Constrain fanout: previously `Promise.all(projects.map(...))` could
projectEntries.map(async (project) => { // spawn dozens of concurrent `git worktree list` and
// `checkIsGitRepository` calls on cold start, each touching the
// worktree process. Concurrency=3 keeps startup latency low while
// bounding peak worktree-process load.
const worktreeConcurrency = 3;
let cursor = 0;
const workers = Array.from({ length: worktreeConcurrency }, async () => {
while (true) {
const nextIndex = cursor;
cursor += 1;
if (nextIndex >= projectEntries.length) return;
const project = projectEntries[nextIndex];
const projectPath = normalizePath(project.path); const projectPath = normalizePath(project.path);
if (!projectPath) return; if (!projectPath) continue;
try { try {
// Use store-cached isGitRepo when available; fall back to direct check for initial worktree discovery // Use store-cached isGitRepo when available; fall back to
// a direct check for projects the Git store hasn't seen yet.
// Forcing `ensureStatus` here also warms the store so the
// PR/render paths downstream can read isGitRepo for free.
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo; const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then(m => m.checkIsGitRepository(projectPath)); const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
if (!isGitRepo) return; if (!isGitRepo) continue;
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) return; if (cancelled || worktrees.length === 0) continue;
worktreesByProject.set(projectPath, worktrees); worktreesByProject.set(projectPath, worktrees);
allWorktrees.push(...worktrees); allWorktrees.push(...worktrees);
} catch { } catch {
// ignore discovery errors // ignore discovery errors
} }
}), }
); });
await Promise.all(workers);
if (cancelled) return; if (cancelled) return;
@@ -436,6 +475,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
}; };
// Skip if we already discovered worktrees for this exact project set.
if (discoveredProjectsRef.current === projectWorktreeDiscoveryKey) {
return;
}
discoveredProjectsRef.current = projectWorktreeDiscoveryKey;
void discoverWorktrees(); void discoverWorktrees();
return () => { return () => {
@@ -508,11 +552,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]); }, [sessions, pinnedSessionIds]);
const sessionOrderIndex = React.useMemo( // Stable signature: id + updatedAt joined. When this string is
() => new Map(sortedSessions.map((session, index) => [session.id, index])), // unchanged, the relative ordering of sessions is identical and the
// derived `sessionOrderIndex` Map can return the previous reference.
// Without this, a fresh `sortedSessions` array (cheap to rebuild) would
// still hand a new Map identity to the entire SessionGroupSection
// memo chain, invalidating sourceGroupNodes, nodeBySessionId, and the
// rest of the down-stream useMemo chain.
const sessionOrderSignature = React.useMemo(
() => sortedSessions.map((s) => `${s.id}:${s.time?.updated ?? 0}`).join('|'),
[sortedSessions], [sortedSessions],
); );
const sessionOrderIndexRef = React.useRef<{ signature: string; map: Map<string, number> } | null>(null);
const sessionOrderIndex = React.useMemo(() => {
const cached = sessionOrderIndexRef.current;
if (cached && cached.signature === sessionOrderSignature) {
return cached.map;
}
const next = new Map(sortedSessions.map((session, index) => [session.id, index]));
sessionOrderIndexRef.current = { signature: sessionOrderSignature, map: next };
return next;
}, [sessionOrderSignature, sortedSessions]);
const childrenMap = React.useMemo(() => { const childrenMap = React.useMemo(() => {
const map = new Map<string, Session[]>(); const map = new Map<string, Session[]>();
sortedSessions.forEach((session) => { sortedSessions.forEach((session) => {
@@ -703,6 +765,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
[collapsedFolderIds, toggleFolderCollapse, createFolder, t], [collapsedFolderIds, toggleFolderCollapse, createFolder, t],
); );
const stableHandleSessionSelect = useStableRenderCallback(handleSessionSelect);
const stableHandleSessionDoubleClick = useStableRenderCallback(handleSessionDoubleClick);
const stableHandleSaveEdit = useStableRenderCallback(handleSaveEdit);
const stableHandleCancelEdit = useStableRenderCallback(handleCancelEdit);
const stableHandleShareSession = useStableRenderCallback(handleShareSession);
const stableHandleCopyShareUrl = useStableRenderCallback(handleCopyShareUrl);
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => { const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
setVisibleSessionCountByGroup((prev) => { setVisibleSessionCountByGroup((prev) => {
const next = new Map(prev); const next = new Map(prev);
@@ -881,6 +953,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessions, sessions,
archivedSessions, archivedSessions,
availableWorktreesByProject, availableWorktreesByProject,
normalizedProjects,
}); });
useArchivedAutoFolders({ useArchivedAutoFolders({
@@ -930,7 +1003,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div> </div>
); );
const { currentSessionDirectory } = useProjectSessionSelection({ useProjectSessionSelection({
projectSections, projectSections,
activeProjectId, activeProjectId,
activeSessionByProject, activeSessionByProject,
@@ -942,8 +1015,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
openNewSessionDraft, openNewSessionDraft,
setActiveMainTab, setActiveMainTab,
setSessionSwitcherOpen, setSessionSwitcherOpen,
sessions,
worktreeMetadata,
}); });
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject); const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
@@ -1214,15 +1285,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
projectHeaderSentinelRefs, projectHeaderSentinelRefs,
}); });
const renderSessionNode = React.useCallback( const renderSessionNode = useStableRenderCallback(
( (
node: SessionNode, node: SessionNode,
depth = 0, depth: number = 0,
groupDirectory?: string | null, groupDirectory?: string | null,
projectId?: string | null, projectId?: string | null,
archivedBucket = false, archivedBucket: boolean = false,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext: 'project' | 'recent' = 'project', renderContext: 'project' | 'recent' = 'project',
renderExtras?: SessionNodeRenderExtras,
): React.ReactNode => ( ): React.ReactNode => (
<SessionNodeItem <SessionNodeItem
node={node} node={node}
@@ -1240,16 +1312,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setEditingId={setEditingId} setEditingId={setEditingId}
editTitle={editTitle} editTitle={editTitle}
setEditTitle={setEditTitle} setEditTitle={setEditTitle}
handleSaveEdit={handleSaveEdit} handleSaveEdit={stableHandleSaveEdit}
handleCancelEdit={handleCancelEdit} handleCancelEdit={stableHandleCancelEdit}
toggleParent={toggleParent} toggleParent={toggleParent}
handleSessionSelect={handleSessionSelect} handleSessionSelect={stableHandleSessionSelect}
handleSessionDoubleClick={handleSessionDoubleClick} handleSessionDoubleClick={stableHandleSessionDoubleClick}
togglePinnedSession={togglePinnedSession} togglePinnedSession={togglePinnedSession}
handleShareSession={handleShareSession} handleShareSession={stableHandleShareSession}
copiedSessionId={copiedSessionId} copiedSessionId={copiedSessionId}
handleCopyShareUrl={handleCopyShareUrl} handleCopyShareUrl={stableHandleCopyShareUrl}
handleUnshareSession={handleUnshareSession} handleUnshareSession={stableHandleUnshareSession}
openSidebarMenuKey={openSidebarMenuKey} openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey} setOpenSidebarMenuKey={setOpenSidebarMenuKey}
renamingFolderId={renamingFolderId} renamingFolderId={renamingFolderId}
@@ -1257,50 +1329,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
getSessionFolderId={getSessionFolderId} getSessionFolderId={getSessionFolderId}
removeSessionFromFolder={removeSessionFromFolder} removeSessionFromFolder={removeSessionFromFolder}
addSessionToFolder={addSessionToFolder} addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename} createFolderAndStartRename={stableCreateFolderAndStartRename}
openContextPanelTab={openContextPanelTab} openContextPanelTab={openContextPanelTab}
handleDeleteSession={handleDeleteSession} handleDeleteSession={stableHandleDeleteSession}
mobileVariant={mobileVariant} mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions} alwaysShowActions={alwaysShowSidebarActions}
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
secondaryMeta={secondaryMeta} secondaryMeta={secondaryMeta}
renderContext={renderContext} renderContext={renderContext}
subtreeContainsActive={renderExtras?.subtreeContainsActive ?? EMPTY_SUBTREE_SET}
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
liveSessionById={liveSessionById}
/> />
), ),
[
currentSessionId,
pinnedSessionIds,
expandedParents,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
notifyOnSubtasks,
editingId,
setEditingId,
editTitle,
setEditTitle,
handleSaveEdit,
handleCancelEdit,
toggleParent,
handleSessionSelect,
handleSessionDoubleClick,
togglePinnedSession,
handleShareSession,
copiedSessionId,
handleCopyShareUrl,
handleUnshareSession,
openSidebarMenuKey,
setOpenSidebarMenuKey,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
removeSessionFromFolder,
addSessionToFolder,
createFolderAndStartRename,
openContextPanelTab,
handleDeleteSession,
mobileVariant,
alwaysShowSidebarActions,
],
); );
const toggleCollapsedGroup = React.useCallback((key: string) => { const toggleCollapsedGroup = React.useCallback((key: string) => {
@@ -1335,7 +1379,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [prVisualSummaryMap]); }, [prVisualSummaryMap]);
const renderGroupSessions = React.useCallback( const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => ( (
group: SessionGroup,
groupKey: string,
projectId?: string | null,
hideGroupLabel?: boolean,
dragHandleProps?: SortableDragHandleProps | null,
compactBodyPadding?: boolean,
scrollContainerRef?: React.RefObject<HTMLElement | null>,
) => (
<SessionGroupSection <SessionGroupSection
group={group} group={group}
groupKey={groupKey} groupKey={groupKey}
@@ -1355,7 +1407,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
showDeletionDialog={showDeletionDialog} showDeletionDialog={showDeletionDialog}
setDeleteFolderConfirm={setDeleteFolderConfirm} setDeleteFolderConfirm={setDeleteFolderConfirm}
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
currentSessionDirectory={currentSessionDirectory}
projectRepoStatus={projectRepoStatus} projectRepoStatus={projectRepoStatus}
lastRepoStatus={lastRepoStatusRef.current} lastRepoStatus={lastRepoStatusRef.current}
showMoreGroupSessions={showMoreGroupSessions} showMoreGroupSessions={showMoreGroupSessions}
@@ -1368,16 +1419,23 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setSessionSwitcherOpen={setSessionSwitcherOpen} setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraftFromTree} openNewSessionDraft={openNewSessionDraftFromTree}
addSessionToFolder={addSessionToFolder} addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename} createFolderAndStartRename={stableCreateFolderAndStartRename}
renamingFolderId={renamingFolderId} renamingFolderId={renamingFolderId}
renameFolderDraft={renameFolderDraft} renameFolderDraft={renameFolderDraft}
setRenameFolderDraft={setRenameFolderDraft} setRenameFolderDraft={setRenameFolderDraft}
setRenamingFolderId={setRenamingFolderId} setRenamingFolderId={setRenamingFolderId}
pinnedSessionIds={pinnedSessionIds} pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents}
sessionOrderIndex={sessionOrderIndex} sessionOrderIndex={sessionOrderIndex}
currentSessionId={currentSessionId}
editingId={editingId}
editTitle={editTitle}
openSidebarMenuKey={openSidebarMenuKey}
liveSessionById={liveSessionById}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch} prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup} onToggleCollapsedGroup={toggleCollapsedGroup}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
scrollContainerRef={scrollContainerRef}
/> />
), ),
[ [
@@ -1393,7 +1451,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
deleteFolder, deleteFolder,
showDeletionDialog, showDeletionDialog,
renderSessionNode, renderSessionNode,
currentSessionDirectory,
projectRepoStatus, projectRepoStatus,
showMoreGroupSessions, showMoreGroupSessions,
resetGroupSessionLimit, resetGroupSessionLimit,
@@ -1405,11 +1462,17 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setSessionSwitcherOpen, setSessionSwitcherOpen,
openNewSessionDraftFromTree, openNewSessionDraftFromTree,
addSessionToFolder, addSessionToFolder,
createFolderAndStartRename, stableCreateFolderAndStartRename,
renamingFolderId, renamingFolderId,
renameFolderDraft, renameFolderDraft,
pinnedSessionIds, pinnedSessionIds,
expandedParents,
sessionOrderIndex, sessionOrderIndex,
currentSessionId,
editingId,
editTitle,
openSidebarMenuKey,
liveSessionById,
prVisualStateByDirectoryBranch, prVisualStateByDirectoryBranch,
toggleCollapsedGroup, toggleCollapsedGroup,
], ],
@@ -1419,174 +1482,40 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<SidebarActivitySections <SidebarActivitySections
sections={activitySections} sections={activitySections}
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
currentSessionId={currentSessionId}
editingId={editingId}
openSidebarMenuKey={openSidebarMenuKey}
variant="section" variant="section"
/> />
) : null; ) : null;
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId); const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled); const {
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds); selectionModeEnabled,
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey); hasSelection,
const multiSelectStoreApi = useSessionMultiSelectStore; selectedIdsSize,
bulkScopeIsArchived,
const handleToggleSelectionMode = React.useCallback(() => { derivedSelectionScope,
useSessionMultiSelectStore.getState().toggleMode(); bulkScopeFolders,
}, []); bulkCanRemoveFromFolder,
const handleExitSelectionMode = React.useCallback(() => { handleToggleSelectionMode,
useSessionMultiSelectStore.getState().disable(); handleExitSelectionMode,
}, []); handleBulkMoveToFolder,
handleBulkCreateFolderAndMove,
const bulkScopeIsArchived = React.useMemo(() => { handleBulkRemoveFromFolder,
if (selectedIds.size === 0) return false; handleBulkDelete,
if (typeof document === 'undefined') return false; confirmBulkDelete,
let sawActive = false; } = useSidebarBulkActions({
let sawArchived = false; isInlineEditing,
for (const id of selectedIds) { showDeletionDialog,
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`); foldersMap,
for (const row of rows) { addSessionsToFolder,
if (row.getAttribute('data-session-archived') === '1') sawArchived = true; removeSessionsFromFolders,
else sawActive = true; createFolderAndStartRename,
} archiveSessions,
} deleteSessions,
return sawArchived && !sawActive; setBulkDeleteConfirm,
}, [selectedIds]); });
const derivedSelectionScope = React.useMemo(() => {
if (selectionScopeKey) return selectionScopeKey;
if (selectedIds.size === 0) return null;
if (typeof document === 'undefined') return null;
for (const id of selectedIds) {
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
const scope = row?.getAttribute('data-session-scope');
if (scope && scope.length > 0) return scope;
}
return null;
}, [selectedIds, selectionScopeKey]);
const bulkScopeFolders = React.useMemo(() => {
if (!derivedSelectionScope) return [];
return foldersMap[derivedSelectionScope] ?? [];
}, [foldersMap, derivedSelectionScope]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return false;
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
for (const folder of scopeFolders) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
return false;
}, [foldersMap, derivedSelectionScope, selectedIds]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
}, [addSessionsToFolder, selectedIds, derivedSelectionScope]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
const newFolder = createFolderAndStartRename(derivedSelectionScope);
if (!newFolder) return;
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
const count = selectedIds.size;
if (count === 0) return;
if (!showDeletionDialog) {
void executeBulkDelete();
return;
}
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog]);
const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null);
await executeBulkDelete();
}, [executeBulkDelete]);
React.useEffect(() => {
if (!selectionModeEnabled) return;
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
const listener = (event: KeyboardEvent) => {
if (isInlineEditing) return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
const modifier = isMac ? event.metaKey : event.ctrlKey;
if (event.key === 'Escape') {
event.preventDefault();
useSessionMultiSelectStore.getState().disable();
return;
}
if (modifier && event.key === 'Backspace') {
event.preventDefault();
handleBulkDelete();
return;
}
if (modifier && (event.key === 'a' || event.key === 'A')) {
const rows = typeof document !== 'undefined'
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
: [];
if (rows.length === 0) return;
event.preventDefault();
const currentScope = multiSelectStoreApi.getState().scopeKey;
const targetScope = currentScope
?? rows[0]?.getAttribute('data-session-scope')
?? null;
const scopeFilter = (el: HTMLElement): boolean => {
if (!targetScope) return true;
return el.getAttribute('data-session-scope') === targetScope;
};
const ids = rows
.filter(scopeFilter)
.map((el) => el.getAttribute('data-session-row'))
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (ids.length === 0) return;
multiSelectStoreApi.getState().replaceAll(ids, targetScope || null);
}
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [handleBulkDelete, isInlineEditing, multiSelectStoreApi, selectionModeEnabled]);
const handleOpenMultiRunFromHeader = React.useCallback(() => { const handleOpenMultiRunFromHeader = React.useCallback(() => {
setActiveMainTab('chat'); setActiveMainTab('chat');
if (mobileVariant) { if (mobileVariant) {
@@ -1670,9 +1599,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
isInlineEditing={isInlineEditing} isInlineEditing={isInlineEditing}
/> />
{selectionModeEnabled && selectedIds.size > 0 ? ( {selectionModeEnabled && hasSelection ? (
<BulkActionBar <BulkActionBar
selectedCount={selectedIds.size} selectedCount={selectedIdsSize}
scopeKey={derivedSelectionScope} scopeKey={derivedSelectionScope}
scopeFolders={bulkScopeFolders} scopeFolders={bulkScopeFolders}
archivedBucket={bulkScopeIsArchived} archivedBucket={bulkScopeIsArchived}
@@ -5,6 +5,11 @@ import type { Session } from '@opencode-ai/sdk/v2';
// Archived buckets routinely grow into the hundreds/thousands; virtualize // Archived buckets routinely grow into the hundreds/thousands; virtualize
// when we cross this row count so the DOM stays bounded. // when we cross this row count so the DOM stays bounded.
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50; const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// Active/worktree groups can also grow large (a single worktree with 80+
// sessions), and unlike the archive they're interactive from the start.
// Virtualize eagerly for non-archived groups to keep the rendered row
// count bounded. With overscan ~8 the visible behavior is identical.
const ACTIVE_VIRTUALIZE_THRESHOLD = 30;
// Compact rows in the archived bucket without nested subagents render // Compact rows in the archived bucket without nested subagents render
// around 24-32px; virtua measures mounted rows and uses this as the initial hint. // around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28; const ARCHIVED_ROW_ESTIMATE_PX = 28;
@@ -18,6 +23,13 @@ import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDn
import type { SortableDragHandleProps } from './sortableItems'; import type { SortableDragHandleProps } from './sortableItems';
import type { GroupSearchData, SessionGroup, SessionNode } from './types'; import type { GroupSearchData, SessionGroup, SessionNode } from './types';
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils'; import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
import {
collectSubtreeContainingId,
computeNodeStructureKey,
nodeContainsSessionId,
resolveMenuOpenSessionId,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
@@ -50,8 +62,16 @@ type Props = {
deleteFolder: (scopeKey: string, folderId: string) => void; deleteFolder: (scopeKey: string, folderId: string) => void;
showDeletionDialog: boolean; showDeletionDialog: boolean;
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>; setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null) => React.ReactNode; renderSessionNode: (
currentSessionDirectory: string | null; node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
projectRepoStatus: Map<string, boolean | null>; projectRepoStatus: Map<string, boolean | null>;
lastRepoStatus: boolean; lastRepoStatus: boolean;
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void; showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
@@ -70,7 +90,13 @@ type Props = {
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>; setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>; setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
pinnedSessionIds: Set<string>; pinnedSessionIds: Set<string>;
expandedParents: Set<string>;
sessionOrderIndex: Map<string, number>; sessionOrderIndex: Map<string, number>;
currentSessionId: string | null;
editingId: string | null;
editTitle: string;
openSidebarMenuKey: string | null;
liveSessionById: Map<string, Session>;
prVisualStateByDirectoryBranch: Map<string, { prVisualStateByDirectoryBranch: Map<string, {
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed'; visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
number: number; number: number;
@@ -97,9 +123,196 @@ type Props = {
onToggleCollapsedGroup: (groupKey: string) => void; onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null; dragHandleProps?: SortableDragHandleProps | null;
compactBodyPadding?: boolean; compactBodyPadding?: boolean;
/**
* Optional scroll container ref threaded from the outer ScrollableOverlay.
* When provided, the virtualization effect can resolve the scrolling
* ancestor synchronously and skip the getComputedStyle walk on every
* render of an expanded archived bucket.
*/
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}; };
export function SessionGroupSection(props: Props): React.ReactNode { const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => {
if (!sessionId) return false;
return group.sessions.some((node) => nodeContainsSessionId(node, sessionId));
};
const groupHasPinnedMembershipChange = (
group: SessionGroup,
prevPinnedSessionIds: Set<string>,
nextPinnedSessionIds: Set<string>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasSessionOrderChange = (
group: SessionGroup,
prevSessionOrderIndex: Map<string, number>,
nextSessionOrderIndex: Map<string, number>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if (prevSessionOrderIndex.get(sessionId) !== nextSessionOrderIndex.get(sessionId)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasExpansionMembershipChange = (
group: SessionGroup,
prevExpandedParents: Set<string>,
nextExpandedParents: Set<string>,
): boolean => {
const bucketTag = group.isArchivedBucket ? 'archived' : 'active';
const visit = (node: SessionNode): boolean => {
const key = `project:${bucketTag}:${node.session.id}`;
if (prevExpandedParents.has(key) !== nextExpandedParents.has(key)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasResolvedSessionChange = (
group: SessionGroup,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) {
return true;
}
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
if (!props.projectId) return undefined;
return props.projectRepoStatus.has(props.projectId)
? props.projectRepoStatus.get(props.projectId)
: undefined;
};
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
// Bail on Object.is for the props that drive the most work: the group
// itself, its key, and the group-level chrome. These change rarely and
// any change should force a re-render of this group.
if (prev.group !== next.group) return false;
if (prev.groupKey !== next.groupKey) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.hideGroupLabel !== next.hideGroupLabel) return false;
if (prev.compactBodyPadding !== next.compactBodyPadding) return false;
if (prev.groupSearchDataByGroup !== next.groupSearchDataByGroup) return false;
if (prev.visibleSessionCount !== next.visibleSessionCount) return false;
if (prev.collapsedGroups !== next.collapsedGroups
&& prev.collapsedGroups.has(prev.groupKey) !== next.collapsedGroups.has(next.groupKey)) {
return false;
}
if (prev.projectRepoStatus !== next.projectRepoStatus
&& getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) {
return false;
}
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
return false;
}
if (prev.expandedParents !== next.expandedParents
&& groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) {
return false;
}
if (prev.sessionOrderIndex !== next.sessionOrderIndex
&& groupHasSessionOrderChange(next.group, prev.sessionOrderIndex, next.sessionOrderIndex)) {
return false;
}
if (prev.currentSessionId !== next.currentSessionId
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
return false;
}
if (prev.editingId !== next.editingId
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
}
if (prev.editTitle !== next.editTitle
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = resolveMenuOpenSessionId(prev.group.sessions, prev.openSidebarMenuKey, 'project', Boolean(prev.group.isArchivedBucket));
const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', Boolean(next.group.isArchivedBucket));
if (prevMenuSessionId || nextMenuSessionId) return false;
}
if (prev.liveSessionById !== next.liveSessionById
&& groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) {
return false;
}
// Per-row / per-state props. The PR-visual-state map flips frequently
// during bootstrap but a single group's value is usually stable, so we
// compare only the value this group actually consumes instead of the
// whole map reference.
if (prev.prVisualStateByDirectoryBranch !== next.prVisualStateByDirectoryBranch) {
const prevVal = prev.group?.directory && prev.group?.branch
? prev.prVisualStateByDirectoryBranch.get(`${prev.group.directory}::${prev.group.branch.trim()}`)
: undefined;
const nextVal = next.group?.directory && next.group?.branch
? next.prVisualStateByDirectoryBranch.get(`${next.group.directory}::${next.group.branch.trim()}`)
: undefined;
if (!Object.is(prevVal, nextVal)) return false;
}
// Other props are typically stable references from the parent. Default
// to reference equality (the cheap path) and only re-render when the
// parent actually swapped something.
return (
prev.hasSessionSearchQuery === next.hasSessionSearchQuery
&& prev.normalizedSessionSearchQuery === next.normalizedSessionSearchQuery
&& prev.hideDirectoryControls === next.hideDirectoryControls
&& prev.collapsedFolderIds === next.collapsedFolderIds
&& prev.toggleFolderCollapse === next.toggleFolderCollapse
&& prev.renameFolder === next.renameFolder
&& prev.deleteFolder === next.deleteFolder
&& prev.showDeletionDialog === next.showDeletionDialog
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
&& prev.renderSessionNode === next.renderSessionNode
&& prev.lastRepoStatus === next.lastRepoStatus
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
&& prev.mobileVariant === next.mobileVariant
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.activeProjectId === next.activeProjectId
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
&& prev.setActiveMainTab === next.setActiveMainTab
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
&& prev.openNewSessionDraft === next.openNewSessionDraft
&& prev.addSessionToFolder === next.addSessionToFolder
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.renamingFolderId === next.renamingFolderId
&& prev.renameFolderDraft === next.renameFolderDraft
&& prev.setRenameFolderDraft === next.setRenameFolderDraft
&& prev.setRenamingFolderId === next.setRenamingFolderId
&& prev.onToggleCollapsedGroup === next.onToggleCollapsedGroup
&& prev.dragHandleProps === next.dragHandleProps
&& prev.scrollContainerRef === next.scrollContainerRef
);
};
function SessionGroupSectionBase(props: Props): React.ReactNode {
const { t } = useI18n(); const { t } = useI18n();
const { const {
group, group,
@@ -138,10 +351,14 @@ export function SessionGroupSection(props: Props): React.ReactNode {
setRenamingFolderId, setRenamingFolderId,
pinnedSessionIds, pinnedSessionIds,
sessionOrderIndex, sessionOrderIndex,
currentSessionId,
editingId,
openSidebarMenuKey,
prVisualStateByDirectoryBranch, prVisualStateByDirectoryBranch,
onToggleCollapsedGroup, onToggleCollapsedGroup,
dragHandleProps, dragHandleProps,
compactBodyPadding = false, compactBodyPadding = false,
scrollContainerRef,
} = props; } = props;
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => { const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
@@ -254,6 +471,79 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]); const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]); const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
// Precompute per-row "subtree contains active session" and "subtree contains
// editing session" lookups once per render. The previous design walked the
// node tree inside SessionNodeItem.areEqual for every row, which is O(M^2)
// across the whole sidebar. These sets let areEqual answer with a single
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
const renderContextForGroup = 'project' as const;
const subtreeContainsActive = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, currentSessionId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, currentSessionId]);
const subtreeContainsEditing = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, editingId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, editingId]);
const menuOpenSessionId = React.useMemo(() => {
if (!openSidebarMenuKey) return null;
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
if (fromSource) return fromSource;
for (const { nodes } of allFoldersForGroup) {
const id = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
if (id) return id;
}
return null;
}, [openSidebarMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap<SessionNode, string> => {
const map = new WeakMap<SessionNode, string>();
const visit = (node: SessionNode): void => {
map.set(node, computeNodeStructureKey(node));
for (const child of node.children) {
visit(child);
}
};
nodes.forEach(visit);
return map;
}, []);
const nodeStructureKeyBySourceNode = React.useMemo(
() => buildNodeStructureKeyByNode(sourceGroupNodes),
[buildNodeStructureKeyByNode, sourceGroupNodes],
);
const nodeStructureKeyByFolderNode = React.useMemo(
() => {
const map = new WeakMap<SessionNode, string>();
allFoldersForGroup.forEach(({ nodes }) => {
nodes.forEach((node) => map.set(node, computeNodeStructureKey(node)));
});
return map;
},
[allFoldersForGroup],
);
const resolveNodeStructureKey = React.useCallback((node: SessionNode): string => {
return nodeStructureKeyBySourceNode.get(node) ?? nodeStructureKeyByFolderNode.get(node) ?? '';
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(child),
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
const totalSessions = ungroupedSessions.length; const totalSessions = ungroupedSessions.length;
const visibleSessions = group.isArchivedBucket const visibleSessions = group.isArchivedBucket
? ungroupedSessions ? ungroupedSessions
@@ -263,15 +553,21 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const remainingCount = totalSessions - visibleSessions.length; const remainingCount = totalSessions - visibleSessions.length;
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0; const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
// Virtualize the archived bucket once it grows past a threshold. The // Virtualize large groups. Archived buckets grow into the hundreds or
// archived list is the only group that can routinely hit hundreds or // thousands of rows; active/worktree groups can also hit 80+ sessions
// thousands of rows (projects accumulate archived sessions over time); // when a single worktree accumulates over time. Both paths share the
// every other group renders eagerly because they're small. All hooks // same virtua Virtualizer; the threshold just controls when we mount
// below MUST stay above the search-empty early-return so they fire in // it. The visible behavior is identical because virtua uses overscan
// the same order every render — rules-of-hooks. // (8) for the buffer zone. All hooks below MUST stay above the
// search-empty early-return so they fire in the same order every
// render — rules-of-hooks.
const shouldVirtualizeArchived = group.isArchivedBucket === true const shouldVirtualizeArchived = group.isArchivedBucket === true
&& !hasSessionSearchQuery && !hasSessionSearchQuery
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD; && visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
const shouldVirtualizeActive = group.isArchivedBucket !== true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD;
const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive;
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null); const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
const archivedScrollRef = React.useRef<HTMLElement | null>(null); const archivedScrollRef = React.useRef<HTMLElement | null>(null);
@@ -284,22 +580,31 @@ export function SessionGroupSection(props: Props): React.ReactNode {
// element and renders rows in the wrong subset / position. // element and renders rows in the wrong subset / position.
const [archivedScrollMargin, setArchivedScrollMargin] = React.useState(0); const [archivedScrollMargin, setArchivedScrollMargin] = React.useState(0);
// Find the nearest scrolling ancestor by walking up the DOM. The sidebar // Resolve the scrolling ancestor. When the parent has threaded a
// routes its scroll through `ScrollableOverlay` higher up the tree; // `scrollContainerRef` (Layer 1.4), use it directly to skip the
// threading a ref through every intermediate component would be invasive // `getComputedStyle` walk on every render of an expanded archived
// for this single use case. // bucket — the walk is one of the more expensive operations in the
// Resolve the scrolling ancestor and measure the virtual container's offset // hot path because it forces a style recalc on every parent up the
// from its content origin, both on every render. The container ref is null // tree. Fall back to the legacy walk only when the ref is missing.
// while the archived bucket is collapsed (the body isn't mounted), so a //
// We also still re-run when the archive flips between expanded/collapsed,
// and on a ResizeObserver-driven layout change of the container, so a
// dep-gated effect that only fires when shouldVirtualizeArchived flips // dep-gated effect that only fires when shouldVirtualizeArchived flips
// would miss the eventual mount and leave the scroll element null forever. // would miss the eventual mount and leave the scroll element null.
// Running on every render lets us pick up the container as soon as const [, setLayoutVersion] = React.useState(0);
// expanding the bucket mounts it; the cached scroll element is reused as React.useEffect(() => {
// long as it still contains the container. Both state setters compare if (!shouldVirtualize) return;
// before writing, so a stable layout produces no state churn. const container = archivedVirtualContainerRef.current;
if (!container) return;
if (typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
ro.observe(container);
return () => ro.disconnect();
}, [shouldVirtualize]);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!shouldVirtualizeArchived) { if (!shouldVirtualize) {
if (archivedScrollEl !== null) setArchivedScrollEl(null); if (archivedScrollEl !== null) setArchivedScrollEl(null);
archivedScrollRef.current = null; archivedScrollRef.current = null;
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0); if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
@@ -312,7 +617,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
return; return;
} }
let scrollEl: HTMLElement | null = archivedScrollEl; let scrollEl: HTMLElement | null = archivedScrollEl;
if (!scrollEl || !scrollEl.contains(container)) { const providedScrollEl = scrollContainerRef?.current ?? null;
if (providedScrollEl && providedScrollEl.contains(container)) {
scrollEl = providedScrollEl;
if (scrollEl !== archivedScrollEl) {
archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl);
return;
}
} else if (!scrollEl || !scrollEl.contains(container)) {
// Walk up to find the nearest scrolling ancestor. Only happens on // Walk up to find the nearest scrolling ancestor. Only happens on
// first mount or if the DOM tree restructured. // first mount or if the DOM tree restructured.
let el: HTMLElement | null = container.parentElement; let el: HTMLElement | null = container.parentElement;
@@ -327,8 +640,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
if (scrollEl !== archivedScrollEl) { if (scrollEl !== archivedScrollEl) {
archivedScrollRef.current = scrollEl; archivedScrollRef.current = scrollEl;
setArchivedScrollEl(scrollEl); setArchivedScrollEl(scrollEl);
// setState triggers a re-render; bail out and let the next pass
// measure the margin against the fresh element.
return; return;
} }
} }
@@ -339,11 +650,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset)); setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
}); });
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) { // Hooks below MUST stay above the search-empty early-return so they
return null; // fire in the same order every render — rules-of-hooks.
} const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
const collected: Session[] = []; const collected: Session[] = [];
const visit = (list: SessionNode[]) => { const visit = (list: SessionNode[]) => {
list.forEach((node) => { list.forEach((node) => {
@@ -353,9 +662,52 @@ export function SessionGroupSection(props: Props): React.ReactNode {
}; };
visit(nodes); visit(nodes);
return collected; return collected;
}; }, []);
// The "delete all in group" handler closes over the full list of
// sessions in this group. Memoize so the recursive flatten only runs
// when the underlying source group nodes change, not on every render.
const allGroupSessions = React.useMemo(
() => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []),
[collectGroupSessions, sourceGroupNodes, group.isArchivedBucket],
);
// Precompute the per-folder "delete all sessions in folder" list once
// per render. The previous design ran a recursive `collectFolderSessions`
// walk inside each folder's render, which is O(F × (S + F)) per group
// render. With F=50 folders and S=200 archived sessions this is
// significant; the precompute makes it O(F + S) once.
const folderSessionsForDeleteById = React.useMemo(() => {
if (!group.isArchivedBucket) return new Map<string, Session[]>();
const result = new Map<string, Session[]>();
const childIdsByParentId = new Map<string, string[]>();
for (const { folder } of allFoldersForGroup) {
if (!folder.parentId) continue;
const existing = childIdsByParentId.get(folder.parentId) ?? [];
existing.push(folder.id);
childIdsByParentId.set(folder.parentId, existing);
}
const visit = (targetFolderId: string, seen: Set<string>): Session[] => {
if (seen.has(targetFolderId)) return [];
seen.add(targetFolderId);
const directEntry = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId);
const collected: Session[] = directEntry ? collectGroupSessions(directEntry.nodes) : [];
const childIds = childIdsByParentId.get(targetFolderId) ?? [];
for (const childId of childIds) {
collected.push(...visit(childId, seen));
}
return collected;
};
for (const { folder } of allFoldersForGroup) {
result.set(folder.id, visit(folder.id, new Set()));
}
return result;
}, [allFoldersForGroup, collectGroupSessions, group.isArchivedBucket]);
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
const isGitProject = projectId && projectRepoStatus.has(projectId) const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId)) ? Boolean(projectRepoStatus.get(projectId))
: lastRepoStatus; : lastRepoStatus;
@@ -437,15 +789,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
const subFolderItems = directSubFolders.length > 0 const subFolderItems = directSubFolders.length > 0
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</> ? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
: undefined; : undefined;
const collectFolderSessions = (targetFolderId: string): Session[] => { const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? [];
const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId);
return [
...collectGroupSessions(directNodes),
...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)),
];
};
const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : [];
return ( return (
<DroppableFolderWrapper key={folder.id} folderId={folder.id}> <DroppableFolderWrapper key={folder.id} folderId={folder.id}>
@@ -485,6 +829,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
}); });
}} }}
renderSessionNode={renderSessionNode} renderSessionNode={renderSessionNode}
getRenderExtras={resolveNodeStructureKey
? (node) => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
})
: undefined}
groupDirectory={group.directory} groupDirectory={group.directory}
projectId={projectId} projectId={projectId}
mobileVariant={mobileVariant} mobileVariant={mobileVariant}
@@ -546,7 +899,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
}} }}
> >
{renderFolderItems()} {renderFolderItems()}
{shouldVirtualizeArchived ? ( {shouldVirtualize ? (
<div ref={archivedVirtualContainerRef}> <div ref={archivedVirtualContainerRef}>
<Virtualizer <Virtualizer
data={visibleSessions} data={visibleSessions}
@@ -555,11 +908,23 @@ export function SessionGroupSection(props: Props): React.ReactNode {
scrollRef={archivedScrollRef} scrollRef={archivedScrollRef}
startMargin={archivedScrollMargin} startMargin={archivedScrollMargin}
> >
{(node) => renderSessionNode(node, 0, group.directory, projectId, true) as React.ReactElement} {(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
}) as React.ReactElement}
</Virtualizer> </Virtualizer>
</div> </div>
) : ( ) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true)) visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
}))
)} )}
{totalSessions === 0 && allFoldersForGroup.length === 0 ? ( {totalSessions === 0 && allFoldersForGroup.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground"> <div className="py-1 text-left typography-micro text-muted-foreground">
@@ -846,3 +1211,5 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</div> </div>
); );
} }
export const SessionGroupSection = React.memo(SessionGroupSectionBase, areGroupPropsEqual);
@@ -21,10 +21,12 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession'; import type { ChildSessionExport } from '@/lib/exportSession';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context'; import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync'; import { useSync } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store'; import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd'; import { DraggableSessionRow } from './sessionFolderDnd';
import { nodeContainsSessionId } from './sessionNodeItemUtils';
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionNode } from './types'; import type { SessionNode } from './types';
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
@@ -61,7 +63,7 @@ type Props = {
setEditingId: (id: string | null) => void; setEditingId: (id: string | null) => void;
editTitle: string; editTitle: string;
setEditTitle: (value: string) => void; setEditTitle: (value: string) => void;
handleSaveEdit: () => void; handleSaveEdit: (titleOverride?: string) => void;
handleCancelEdit: () => void; handleCancelEdit: () => void;
toggleParent: (expansionKey: string) => void; toggleParent: (expansionKey: string) => void;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void; handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
@@ -83,133 +85,58 @@ type Props = {
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean }) => void; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean }) => void;
mobileVariant: boolean; mobileVariant: boolean;
alwaysShowActions: boolean; alwaysShowActions: boolean;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: SecondaryMeta | null, renderContext?: 'project' | 'recent') => React.ReactNode; renderSessionNode: (
node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: SecondaryMeta | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
secondaryMeta?: SecondaryMeta | null; secondaryMeta?: SecondaryMeta | null;
renderContext?: 'project' | 'recent'; renderContext?: 'project' | 'recent';
}; /**
* Precomputed set of session IDs whose subtree contains the current
const getNodeChildSignature = (node: SessionNode): string => { * active session. Computed once per SessionGroupSection render (when
if (node.children.length === 0) { * currentSessionId changes) instead of being recomputed in every row's
return ''; * React.memo comparator.
} */
subtreeContainsActive: Set<string>;
return node.children /**
.map((child) => `${child.session.id}:${child.children.length}`) * Precomputed set of session IDs whose subtree contains the session
.join('|'); * currently being edited. Same rationale as subtreeContainsActive.
}; */
subtreeContainsEditing: Set<string>;
const treeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => { /**
if (!sessionId) { * Precomputed session ID of the row whose sidebar menu is open, or null
return false; * if no menu is open. Only one row can have its menu open at a time.
} */
menuOpenSessionId: string | null;
if (node.session.id === sessionId) { /**
return true; * Precomputed structural key for this node. Encodes the IDs and child
} * counts of all descendants so a reference-only change to `node` (e.g.
* a fresh tree rebuild) can be detected with a single string compare
for (const child of node.children) { * instead of a recursive walk per row.
if (treeContainsSessionId(child, sessionId)) { */
return true; nodeStructureKey: string;
} /**
} * Resolves the per-row render extras for each child node. SessionGroupSection
* walks the whole tree once to precompute the structure key for every
return false; * descendant; SessionNodeItem's recursive child render uses this lookup
}; * to fetch the right key for each child it produces.
*/
const treeContainsMenuKey = ( childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
node: SessionNode, /**
menuKey: string | null, * Batched index of live session objects keyed by id. The previous
renderContext: 'project' | 'recent', * implementation called `useSession(session.id)` per row, which used
archivedBucket: boolean, * `findLiveSession` to iterate every child-store on every SSE event.
): boolean => { * With M visible rows that's M×child-stores per event; the batched
if (!menuKey) { * map turns it into a single Map.get per row. The parent falls back
return false; * to `useSession` only when this map returns undefined.
} */
liveSessionById: Map<string, Session>;
const nodeMenuKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${node.session.id}`;
if (nodeMenuKey === menuKey) {
return true;
}
for (const child of node.children) {
if (treeContainsMenuKey(child, menuKey, renderContext, archivedBucket)) {
return true;
}
}
return false;
};
const areEqual = (prev: Props, next: Props): boolean => {
const prevSession = prev.node.session;
const nextSession = next.node.session;
const prevSessionId = prevSession.id;
const nextSessionId = nextSession.id;
if (prevSessionId !== nextSessionId) return false;
if (prev.node.session !== next.node.session) return false;
if (getNodeChildSignature(prev.node) !== getNodeChildSignature(next.node)) return false;
if (prev.depth !== next.depth) return false;
if (prev.groupDirectory !== next.groupDirectory) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.archivedBucket !== next.archivedBucket) return false;
if (prev.currentSessionId !== next.currentSessionId) {
const prevActiveInTree = treeContainsSessionId(prev.node, prev.currentSessionId);
const nextActiveInTree = treeContainsSessionId(next.node, next.currentSessionId);
if (prevActiveInTree || nextActiveInTree) {
return false;
}
}
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
// Expansion is keyed per render context, so compare the composite key
// matching the one isExpanded reads from in render. If a session appears
// in two contexts (project + recent), they have independent state.
{
const prevRenderContext = prev.renderContext ?? 'project';
const nextRenderContext = next.renderContext ?? 'project';
const prevArchived = prev.archivedBucket ?? false;
const nextArchived = next.archivedBucket ?? false;
const prevExpansionKey = `${prevRenderContext}:${prevArchived ? 'archived' : 'active'}:${prevSessionId}`;
const nextExpansionKey = `${nextRenderContext}:${nextArchived ? 'archived' : 'active'}:${nextSessionId}`;
if (prev.expandedParents.has(prevExpansionKey) !== next.expandedParents.has(nextExpansionKey)) return false;
}
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
if (prev.editingId !== next.editingId) {
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
if (prevEditingInTree || nextEditingInTree) {
return false;
}
}
if (prev.editTitle !== next.editTitle) {
const prevEditingInTree = treeContainsSessionId(prev.node, prev.editingId);
const nextEditingInTree = treeContainsSessionId(next.node, next.editingId);
if (prevEditingInTree || nextEditingInTree) {
return false;
}
}
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
const prevMenuInTree = treeContainsMenuKey(prev.node, prev.openSidebarMenuKey, prev.renderContext ?? 'project', prev.archivedBucket ?? false);
const nextMenuInTree = treeContainsMenuKey(next.node, next.openSidebarMenuKey, next.renderContext ?? 'project', next.archivedBucket ?? false);
if (prevMenuInTree !== nextMenuInTree) return false;
const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(prev.groupDirectory ?? null);
const nextDirectory = normalizePath((nextSession as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(next.groupDirectory ?? null);
if (prevDirectory !== nextDirectory) return false;
if ((prev.secondaryMeta?.projectLabel ?? null) !== (next.secondaryMeta?.projectLabel ?? null)) return false;
if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false;
if (prev.mobileVariant !== next.mobileVariant) return false;
if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
if (prev.renamingFolderId !== next.renamingFolderId) return false;
return true;
}; };
function SessionNodeItemComponent(props: Props): React.ReactNode { function SessionNodeItemComponent(props: Props): React.ReactNode {
@@ -255,6 +182,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
renderSessionNode, renderSessionNode,
secondaryMeta, secondaryMeta,
renderContext = 'project', renderContext = 'project',
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
childRenderExtrasFor,
liveSessionById,
} = props; } = props;
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel); const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel); const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
@@ -302,11 +234,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null); const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null);
const handleSaveEditRef = React.useRef(handleSaveEdit); const handleSaveEditRef = React.useRef(handleSaveEdit);
handleSaveEditRef.current = handleSaveEdit; handleSaveEditRef.current = handleSaveEdit;
const [renameDraft, setRenameDraft] = React.useState(editTitle);
const renameDraftRef = React.useRef(renameDraft);
renameDraftRef.current = renameDraft;
const renameTargetRef = React.useRef<string | null>(null);
const formRef = React.useRef<HTMLFormElement>(null); const formRef = React.useRef<HTMLFormElement>(null);
const session = node.session; const session = node.session;
const liveSession = useSession(session.id); // Batched live-session lookup. `liveSessionById` is built once per
const resolvedSession = liveSession ?? session; // Sidebar render from the same `useAllLiveSessions` selector that
// `useSession` would have iterated per child-store, so a Map.get
// here is equivalent in observed state but O(1) per row instead of
// O(child-stores). Falls back to the row session when the live map
// hasn't seen this id yet (sub-render latency between when a session
// is created and when the SSE-driven aggregate picks it up).
const resolvedSession = liveSessionById.get(session.id) ?? session;
const sessionDirectory = const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null) normalizePath((session as Session & { directory?: string | null }).directory ?? null)
@@ -477,13 +419,25 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
if (editingId !== session.id) return; if (editingId !== session.id) return;
const handleDocMouseDown = (e: MouseEvent) => { const handleDocMouseDown = (e: MouseEvent) => {
if (formRef.current && !formRef.current.contains(e.target as Node)) { if (formRef.current && !formRef.current.contains(e.target as Node)) {
handleSaveEditRef.current(); handleSaveEditRef.current(renameDraftRef.current);
} }
}; };
document.addEventListener('mousedown', handleDocMouseDown); document.addEventListener('mousedown', handleDocMouseDown);
return () => document.removeEventListener('mousedown', handleDocMouseDown); return () => document.removeEventListener('mousedown', handleDocMouseDown);
}, [editingId, session.id]); }, [editingId, session.id]);
React.useLayoutEffect(() => {
if (editingId !== session.id) {
if (renameTargetRef.current === session.id) {
renameTargetRef.current = null;
}
return;
}
if (renameTargetRef.current === session.id) return;
renameTargetRef.current = session.id;
setRenameDraft(editTitle);
}, [editingId, editTitle, session.id]);
if (editingId === session.id) { if (editingId === session.id) {
return ( return (
<div <div
@@ -496,24 +450,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
className="flex w-full items-center gap-2" className="flex w-full items-center gap-2"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
handleSaveEdit(); handleSaveEdit(renameDraft);
}} }}
> >
<input <input
value={editTitle} value={renameDraft}
onChange={(event) => setEditTitle(event.target.value)} onChange={(event) => setRenameDraft(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground" className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus autoFocus
placeholder={t('sessions.sidebar.session.menu.rename')} placeholder={t('sessions.sidebar.session.menu.rename')}
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.stopPropagation();
handleCancelEdit(); handleCancelEdit();
return; return;
} }
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}} }}
/> />
<button <button
@@ -1030,18 +981,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
) : ( ) : (
<button <button
type="button" type="button"
onPointerDown={handleRowPointerDown} onPointerDown={handleRowPointerDown}
onPointerUp={handleRowPointerEnd} onPointerUp={handleRowPointerEnd}
onPointerCancel={handleRowPointerEnd} onPointerCancel={handleRowPointerEnd}
onMouseDown={handleRowMouseDown} onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)} onClick={(event) => handleRowSelect(event)}
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleSessionDoubleClick(session.id, sessionTitle); handleSessionDoubleClick(session.id, sessionTitle);
}} }}
className={cn( className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]', 'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
isTouchPressed && 'bg-interactive-hover/70', isTouchPressed && 'bg-interactive-hover/70',
alwaysShowActions alwaysShowActions
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass) ? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
: revealPaddingClass : revealPaddingClass
@@ -1159,7 +1110,26 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
</ContextMenu.Root> </ContextMenu.Root>
</DraggableSessionRow> </DraggableSessionRow>
{hasChildren && isExpanded {hasChildren && isExpanded
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext)) ? node.children.map((child): React.ReactNode => {
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
? childRenderExtrasFor(child)
: {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: '',
};
return renderSessionNode(
child,
depth + 1,
sessionDirectory ?? groupDirectory,
projectId,
archivedBucket,
undefined,
renderContext,
childRenderExtras,
);
})
: null} : null}
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}> <Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
<DialogContent showCloseButton={false} className="max-w-sm gap-5"> <DialogContent showCloseButton={false} className="max-w-sm gap-5">
@@ -1213,4 +1183,194 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
); );
} }
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual); const getNodeSessionDirectory = (node: SessionNode): string | null => {
return normalizePath((node.session as Session & { directory?: string | null }).directory ?? null);
};
const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => {
return (prev?.projectLabel ?? null) === (next?.projectLabel ?? null)
&& (prev?.branchLabel ?? null) === (next?.branchLabel ?? null);
};
const getMenuSessionIdFromKey = (props: Props): string | null => {
if (!props.openSidebarMenuKey) return null;
const bucketTag = props.archivedBucket ? 'archived' : 'active';
const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`;
return props.openSidebarMenuKey.startsWith(prefix)
? props.openSidebarMenuKey.slice(prefix.length)
: null;
};
const getRelevantMenuSessionId = (props: Props): string | null => {
return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props);
};
const subtreeContainsSession = (
props: Props,
sessionId: string | null,
precomputed: Set<string>,
): boolean => {
if (!sessionId) return false;
if (precomputed.has(props.node.session.id)) return true;
return nodeContainsSessionId(props.node, sessionId);
};
const hasSetMembershipChangeInNode = (
prevNode: SessionNode,
nextNode: SessionNode,
prevSet: Set<string>,
nextSet: Set<string>,
getKey: (node: SessionNode) => string,
): boolean => {
if (prevNode.session.id !== nextNode.session.id) return true;
const key = getKey(prevNode);
if (prevSet.has(key) !== nextSet.has(key)) return true;
if (prevNode.children.length !== nextNode.children.length) return true;
for (let i = 0; i < prevNode.children.length; i += 1) {
if (hasSetMembershipChangeInNode(prevNode.children[i], nextNode.children[i], prevSet, nextSet, getKey)) {
return true;
}
}
return false;
};
const hasResolvedSessionChangeInNode = (
prevNode: SessionNode,
nextNode: SessionNode,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
if (prevNode.session.id !== nextNode.session.id) return true;
const sessionId = prevNode.session.id;
if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) {
return true;
}
if (prevNode.children.length !== nextNode.children.length) return true;
for (let i = 0; i < prevNode.children.length; i += 1) {
if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) {
return true;
}
}
return false;
};
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
const nextBucketTag = next.archivedBucket ? 'archived' : 'active';
return hasSetMembershipChangeInNode(
prev.node,
next.node,
prev.expandedParents,
next.expandedParents,
(node) => `${prev.renderContext ?? 'project'}:${prevBucketTag}:${node.session.id}`,
) || hasSetMembershipChangeInNode(
prev.node,
next.node,
prev.expandedParents,
next.expandedParents,
(node) => `${next.renderContext ?? 'project'}:${nextBucketTag}:${node.session.id}`,
);
};
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
if (prev.node.session.id !== next.node.session.id) return false;
if (prev.depth !== next.depth) return false;
if (prev.groupDirectory !== next.groupDirectory) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.archivedBucket !== next.archivedBucket) return false;
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
if (prev.mobileVariant !== next.mobileVariant) return false;
if (prev.alwaysShowActions !== next.alwaysShowActions) return false;
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
if (prev.nodeStructureKey !== next.nodeStructureKey) return false;
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
if (prev.liveSessionById !== next.liveSessionById
&& hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) {
return false;
}
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) {
return false;
}
if (prev.expandedParents !== next.expandedParents && hasExpansionMembershipChange(prev, next)) {
return false;
}
if (prev.currentSessionId !== next.currentSessionId
&& (
subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive)
|| subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive)
)) {
return false;
}
if (prev.editingId !== next.editingId
&& (
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
return false;
}
if (prev.editTitle !== next.editTitle
&& (
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|| subtreeContainsSession(next, next.editingId, next.subtreeContainsEditing)
)) {
return false;
}
if (prev.copiedSessionId !== next.copiedSessionId
&& (
nodeContainsSessionId(prev.node, prev.copiedSessionId)
|| nodeContainsSessionId(next.node, next.copiedSessionId)
)) {
return false;
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next);
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
return false;
}
}
if (prev.renamingFolderId !== next.renamingFolderId) {
const prevMenuSessionId = getRelevantMenuSessionId(prev);
const nextMenuSessionId = getRelevantMenuSessionId(next);
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
return false;
}
}
return prev.setEditingId === next.setEditingId
&& prev.setEditTitle === next.setEditTitle
&& prev.handleSaveEdit === next.handleSaveEdit
&& prev.handleCancelEdit === next.handleCancelEdit
&& prev.toggleParent === next.toggleParent
&& prev.handleSessionSelect === next.handleSessionSelect
&& prev.handleSessionDoubleClick === next.handleSessionDoubleClick
&& prev.togglePinnedSession === next.togglePinnedSession
&& prev.handleShareSession === next.handleShareSession
&& prev.handleCopyShareUrl === next.handleCopyShareUrl
&& prev.handleUnshareSession === next.handleUnshareSession
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
&& prev.getFoldersForScope === next.getFoldersForScope
&& prev.getSessionFolderId === next.getSessionFolderId
&& prev.removeSessionFromFolder === next.removeSessionFromFolder
&& prev.addSessionToFolder === next.addSessionToFolder
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.openContextPanelTab === next.openContextPanelTab
&& prev.handleDeleteSession === next.handleDeleteSession
&& prev.renderSessionNode === next.renderSessionNode;
};
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
@@ -3,6 +3,12 @@ import { cn } from '@/lib/utils';
import type { SessionNode } from './types'; import type { SessionNode } from './types';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import {
collectSubtreeContainingId,
computeNodeStructureKey,
resolveMenuOpenSessionId,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
type ActivityItem = { type ActivityItem = {
node: SessionNode; node: SessionNode;
@@ -22,17 +28,34 @@ type ActivitySection = {
type Props = { type Props = {
sections: ActivitySection[]; sections: ActivitySection[];
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext?: 'project' | 'recent') => React.ReactNode; renderSessionNode: (
node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
currentSessionId: string | null;
editingId: string | null;
openSidebarMenuKey: string | null;
variant?: 'section' | 'flat'; variant?: 'section' | 'flat';
initialVisibleCount?: number; initialVisibleCount?: number;
batchSize?: number; batchSize?: number;
}; };
type RenderExtras = SessionNodeRenderExtras;
const MAX_VISIBLE_RECENT_SESSIONS = 7; const MAX_VISIBLE_RECENT_SESSIONS = 7;
export function SidebarActivitySections({ export function SidebarActivitySections({
sections, sections,
renderSessionNode, renderSessionNode,
currentSessionId,
editingId,
openSidebarMenuKey,
variant = 'section', variant = 'section',
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS, initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
batchSize = MAX_VISIBLE_RECENT_SESSIONS, batchSize = MAX_VISIBLE_RECENT_SESSIONS,
@@ -77,6 +100,36 @@ export function SidebarActivitySections({
}); });
}, [batchSize]); }, [batchSize]);
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
const subtreeContainsActive = new Set<string>();
collectSubtreeContainingId(nodes, currentSessionId, subtreeContainsActive);
const subtreeContainsEditing = new Set<string>();
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
const nodeStructureKeyByNode = new WeakMap<SessionNode, string>();
const visit = (node: SessionNode): void => {
nodeStructureKeyByNode.set(node, computeNodeStructureKey(node));
node.children.forEach(visit);
};
nodes.forEach(visit);
const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
childRenderExtrasFor,
});
return (node: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
childRenderExtrasFor,
});
}, [currentSessionId, editingId, openSidebarMenuKey]);
const visibleSections = sections.filter((section) => section.items.length > 0); const visibleSections = sections.filter((section) => section.items.length > 0);
if (visibleSections.length === 0) { if (visibleSections.length === 0) {
return null; return null;
@@ -93,11 +146,22 @@ export function SidebarActivitySections({
const visibleItems = section.items.slice(0, visibleLimit); const visibleItems = section.items.slice(0, visibleLimit);
const remainingCount = section.items.length - visibleItems.length; const remainingCount = section.items.length - visibleItems.length;
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0; const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
const renderItem = (item: ActivityItem) => renderSessionNode(
item.node,
0,
item.groupDirectory,
item.projectId,
false,
item.secondaryMeta,
'recent',
getRenderExtras(item.node),
);
if (flatVariant) { if (flatVariant) {
return ( return (
<div key={section.key} className="space-y-0.5"> <div key={section.key} className="space-y-0.5">
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))} {visibleItems.map(renderItem)}
{remainingCount > 0 ? ( {remainingCount > 0 ? (
<button <button
type="button" type="button"
@@ -126,7 +190,7 @@ export function SidebarActivitySections({
</button> </button>
{!isCollapsed ? ( {!isCollapsed ? (
<div className={cn('space-y-0.5 pl-7')}> <div className={cn('space-y-0.5 pl-7')}>
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))} {visibleItems.map(renderItem)}
{remainingCount > 0 ? ( {remainingCount > 0 ? (
<button <button
type="button" type="button"
@@ -42,7 +42,15 @@ type Props = {
hasSessionSearchQuery: boolean; hasSessionSearchQuery: boolean;
emptyState: React.ReactNode; emptyState: React.ReactNode;
searchEmptyState: React.ReactNode; searchEmptyState: React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => React.ReactNode; renderGroupSessions: (
group: SessionGroup,
groupKey: string,
projectId?: string | null,
hideGroupLabel?: boolean,
dragHandleProps?: SortableDragHandleProps | null,
compactBodyPadding?: boolean,
scrollContainerRef?: React.RefObject<HTMLElement | null>,
) => React.ReactNode;
homeDirectory: string | null; homeDirectory: string | null;
collapsedProjects: Set<string>; collapsedProjects: Set<string>;
hideDirectoryControls: boolean; hideDirectoryControls: boolean;
@@ -78,6 +86,39 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
); );
// Threaded into SessionGroupSection so the archived-bucket virtualizer
// can resolve the scrolling ancestor synchronously (no getComputedStyle
// walk) and skip the cost of a style recalc on every render.
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
// Memoize the result of getOrderedGroups. The callback is stable
// (deps: [groupOrderByProject]) and `section.groups` is a stable
// reference from useSessionSidebarSections, but the caller discards
// the result on every render and the callback allocates a new array
// each time. With many projects and many sidebar re-renders this
// builds O(P) arrays per render. The cache returns the same array
// reference when the inputs haven't changed, so the downstream
// orderedGroups.filter/find work and any consumer-memoization see a
// stable reference.
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
const cachedGetOrderedGroups = (projectId: string, groups: SessionGroup[]): SessionGroup[] => {
const cache = orderedGroupsCacheRef.current;
const hit = cache.get(projectId);
if (hit && hit.groups === groups) {
return hit.ordered;
}
const ordered = props.getOrderedGroups(projectId, groups);
cache.set(projectId, { groups, ordered });
// Bound the cache so re-ordering projects (which replaces the
// projects list and invalidates every projectId) doesn't grow
// unboundedly.
if (cache.size > 256) {
const firstKey = cache.keys().next().value;
if (firstKey !== undefined) cache.delete(firstKey);
}
return ordered;
};
if (props.sharedSessionsOnly) { if (props.sharedSessionsOnly) {
return ( return (
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}> <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
@@ -96,7 +137,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
} }
return ( return (
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}> <ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
{props.topContent} {props.topContent}
{props.showOnlyMainWorkspace ? ( {props.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1"> <div className="space-y-[0.6rem] py-1">
@@ -124,7 +165,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
const hideGroupLabel = group.id === primaryGroup.id; const hideGroupLabel = group.id === primaryGroup.id;
return ( return (
<React.Fragment key={groupKey}> <React.Fragment key={groupKey}>
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true)} {props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true, scrollContainerRef)}
</React.Fragment> </React.Fragment>
); );
}); });
@@ -158,7 +199,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
const isCollapsed = props.collapsedProjects.has(projectKey); const isCollapsed = props.collapsedProjects.has(projectKey);
const isActiveProject = projectKey === props.activeProjectId; const isActiveProject = projectKey === props.activeProjectId;
const isRepo = props.projectRepoStatus.get(projectKey); const isRepo = props.projectRepoStatus.get(projectKey);
const orderedGroups = props.getOrderedGroups(projectKey, section.groups); const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null; const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
const nestedGroups = rootGroup const nestedGroups = rootGroup
? orderedGroups.filter((group) => group.id !== rootGroup.id) ? orderedGroups.filter((group) => group.id !== rootGroup.id)
@@ -223,13 +264,13 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
}); });
}} }}
> >
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true) : null} {rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}> <SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
{nestedGroups.map((group) => { {nestedGroups.map((group) => {
const groupKey = `${projectKey}:${group.id}`; const groupKey = `${projectKey}:${group.id}`;
return ( return (
<SortableGroupItem key={group.id} id={group.id} disabled={props.isInlineEditing}> <SortableGroupItem key={group.id} id={group.id} disabled={props.isInlineEditing}>
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps)} {(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps, undefined, scrollContainerRef)}
</SortableGroupItem> </SortableGroupItem>
); );
})} })}
@@ -61,6 +61,16 @@ export const useProjectRepoStatus = (args: Args): void => {
// any single project's branch settles (the old N² cascade). // any single project's branch settles (the old N² cascade).
const resolvedInputKeyByProjectId = React.useRef<Map<string, string>>(new Map()); const resolvedInputKeyByProjectId = React.useRef<Map<string, string>>(new Map());
// TTL cache: when a project's `gitRepoStatus` refreshes (it can fire on
// every background poll even if the branch is unchanged), skip the
// `getRootBranch` re-resolution if we resolved the same input within
// the TTL window. 5 minutes matches the polling interval that typically
// drives these refreshes, so we always serve cached results on
// background updates and only re-resolve on cold start or actual
// branch changes (those still invalidate via the input-key check).
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -73,13 +83,16 @@ export const useProjectRepoStatus = (args: Args): void => {
for (const id of resolvedInputKeyByProjectId.current.keys()) { for (const id of resolvedInputKeyByProjectId.current.keys()) {
if (!validIds.has(id)) { if (!validIds.has(id)) {
resolvedInputKeyByProjectId.current.delete(id); resolvedInputKeyByProjectId.current.delete(id);
rootBranchCacheRef.current.delete(id);
} }
} }
const now = Date.now();
const pending = normalizedProjects.filter((project) => { const pending = normalizedProjects.filter((project) => {
const status = gitRepoStatus.get(project.normalizedPath); const status = gitRepoStatus.get(project.normalizedPath);
if (status?.isGitRepo === false) { if (status?.isGitRepo === false) {
resolvedInputKeyByProjectId.current.delete(project.id); resolvedInputKeyByProjectId.current.delete(project.id);
rootBranchCacheRef.current.delete(project.id);
return false; return false;
} }
if (status?.isGitRepo !== true || status.branch === null) { if (status?.isGitRepo !== true || status.branch === null) {
@@ -88,7 +101,21 @@ export const useProjectRepoStatus = (args: Args): void => {
const currentBranch = status.branch.trim(); const currentBranch = status.branch.trim();
const currentInputKey = `${project.normalizedPath}\0${currentBranch}`; const currentInputKey = `${project.normalizedPath}\0${currentBranch}`;
const lastInputKey = resolvedInputKeyByProjectId.current.get(project.id); const lastInputKey = resolvedInputKeyByProjectId.current.get(project.id);
return lastInputKey === undefined || lastInputKey !== currentInputKey; if (lastInputKey === currentInputKey) {
// We've already resolved this exact (path, branch) pair.
// The TTL cache is just an extra protection for the case
// where the input key was reset by a transient blip —
// keep the existing map entry fresh so future re-renders
// hit the cache instead of refetching.
const cached = rootBranchCacheRef.current.get(project.id);
if (cached) cached.at = now;
return false;
}
// Same input? Serve from TTL cache if it's still warm.
if (lastInputKey !== undefined && now - (rootBranchCacheRef.current.get(project.id)?.at ?? 0) < ROOT_BRANCH_TTL_MS) {
return false;
}
return true;
}); });
if (pending.length === 0) { if (pending.length === 0) {
@@ -113,6 +140,7 @@ export const useProjectRepoStatus = (args: Args): void => {
return; return;
} }
const nowAfter = Date.now();
setProjectRootBranches((prev) => { setProjectRootBranches((prev) => {
const next = new Map(prev); const next = new Map(prev);
resolved.forEach(({ id, branch }) => { resolved.forEach(({ id, branch }) => {
@@ -122,8 +150,11 @@ export const useProjectRepoStatus = (args: Args): void => {
}); });
return next; return next;
}); });
resolved.forEach(({ id, inputKey }) => { resolved.forEach(({ id, inputKey, branch }) => {
resolvedInputKeyByProjectId.current.set(id, inputKey); resolvedInputKeyByProjectId.current.set(id, inputKey);
if (branch) {
rootBranchCacheRef.current.set(id, { branch, at: nowAfter });
}
}); });
}; };
void run(); void run();
@@ -133,5 +164,9 @@ export const useProjectRepoStatus = (args: Args): void => {
cancelled = true; cancelled = true;
clearTimeout(timer); clearTimeout(timer);
}; };
// ROOT_BRANCH_TTL_MS is a module-level constant; intentionally not
// in the deps array since it never changes during the component
// lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]); }, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
}; };
@@ -5,11 +5,22 @@ import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '..
type WorktreeMeta = { path: string }; type WorktreeMeta = { path: string };
type NormalizedProject = { id: string; normalizedPath: string };
type Args = { type Args = {
isVSCode: boolean; isVSCode: boolean;
sessions: Session[]; sessions: Session[];
archivedSessions: Session[]; archivedSessions: Session[];
availableWorktreesByProject: Map<string, WorktreeMeta[]>; availableWorktreesByProject: Map<string, WorktreeMeta[]>;
/**
* The set of normalized projects the sidebar will render. Used in
* Layer 4.13 to precompute the allowed directory set so the per-row
* `sessionsByDirectory` Map only contains buckets the sidebar will
* actually consume. With 10 projects × 5 worktrees and 100 sessions
* per directory this drops the Map from N entries to the small
* subset the sidebar needs.
*/
normalizedProjects: NormalizedProject[];
}; };
export const useProjectSessionLists = (args: Args) => { export const useProjectSessionLists = (args: Args) => {
@@ -18,8 +29,32 @@ export const useProjectSessionLists = (args: Args) => {
sessions, sessions,
archivedSessions, archivedSessions,
availableWorktreesByProject, availableWorktreesByProject,
normalizedProjects,
} = args; } = args;
// Precompute the set of directories the sidebar will ever ask about:
// every project's normalized path plus the path of each registered
// worktree. Walking this set is O(P + W) per Sidebar render and lets
// us skip the bulk of `sessions` (whose directory is not associated
// with a known project) when building `sessionsByDirectory`.
const allowedDirectories = React.useMemo(() => {
const set = new Set<string>();
normalizedProjects.forEach((project) => {
if (project.normalizedPath) {
set.add(project.normalizedPath);
}
});
if (!isVSCode) {
for (const worktrees of availableWorktreesByProject.values()) {
for (const worktree of worktrees) {
const normalized = normalizePath(worktree.path);
if (normalized) set.add(normalized);
}
}
}
return set;
}, [normalizedProjects, availableWorktreesByProject, isVSCode]);
const sessionsByDirectory = React.useMemo(() => { const sessionsByDirectory = React.useMemo(() => {
const next = new Map<string, Session[]>(); const next = new Map<string, Session[]>();
sessions.forEach((session) => { sessions.forEach((session) => {
@@ -27,13 +62,21 @@ export const useProjectSessionLists = (args: Args) => {
if (!directory) { if (!directory) {
return; return;
} }
// Skip sessions whose directory doesn't belong to any known
// project or worktree. Without this filter the Map grows with
// every session the server has ever seen, even ones for
// long-removed worktrees; the sidebar's downstream filters
// would then drop them anyway.
if (!allowedDirectories.has(directory)) {
return;
}
const collection = next.get(directory) ?? []; const collection = next.get(directory) ?? [];
collection.push(session); collection.push(session);
next.set(directory, collection); next.set(directory, collection);
}); });
return next; return next;
}, [sessions]); }, [sessions, allowedDirectories]);
const getSessionsForProject = React.useCallback( const getSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => { (project: { normalizedPath: string }) => {
@@ -22,11 +22,9 @@ type Args = {
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void; openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
setActiveMainTab: (tab: MainTab) => void; setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void; setSessionSwitcherOpen: (open: boolean) => void;
sessions: Session[];
worktreeMetadata: Map<string, { path?: string | null }>;
}; };
export const useProjectSessionSelection = (args: Args): { currentSessionDirectory: string | null } => { export const useProjectSessionSelection = (args: Args): void => {
const { const {
projectSections, projectSections,
activeProjectId, activeProjectId,
@@ -39,8 +37,6 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
openNewSessionDraft, openNewSessionDraft,
setActiveMainTab, setActiveMainTab,
setSessionSwitcherOpen, setSessionSwitcherOpen,
sessions,
worktreeMetadata,
} = args; } = args;
const projectSessionMeta = React.useMemo(() => { const projectSessionMeta = React.useMemo(() => {
@@ -101,6 +97,7 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
if (previousActiveProjectRef.current === activeProjectId) { if (previousActiveProjectRef.current === activeProjectId) {
return; return;
} }
const section = projectSections.find((item) => item.project.id === activeProjectId); const section = projectSections.find((item) => item.project.id === activeProjectId);
if (!section) { if (!section) {
return; return;
@@ -173,20 +170,4 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
}); });
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]); }, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
const currentSessionDirectory = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
const metadataPath = worktreeMetadata.get(currentSessionId)?.path;
if (metadataPath) {
return normalizePath(metadataPath) ?? metadataPath;
}
const activeSession = sessions.find((session) => session.id === currentSessionId);
if (!activeSession) {
return null;
}
return normalizePath((activeSession as Session & { directory?: string | null }).directory ?? null);
}, [currentSessionId, sessions, worktreeMetadata]);
return { currentSessionDirectory };
}; };
@@ -105,9 +105,9 @@ export const useSessionActions = (args: Args) => {
args.setEditTitle(sessionTitle); args.setEditTitle(sessionTitle);
}, [args]); }, [args]);
const handleSaveEdit = React.useCallback(async () => { const handleSaveEdit = React.useCallback(async (titleOverride?: string) => {
if (!args.editingId) return; if (!args.editingId) return;
const trimmed = args.editTitle.trim(); const trimmed = (titleOverride ?? args.editTitle).trim();
if (trimmed) { if (trimmed) {
await args.updateSessionTitle(args.editingId, trimmed); await args.updateSessionTitle(args.editingId, trimmed);
} }
@@ -0,0 +1,237 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
type Args = {
isInlineEditing: boolean;
showDeletionDialog: boolean;
foldersMap: Record<string, SessionFolder[]>;
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
setBulkDeleteConfirm: React.Dispatch<React.SetStateAction<{
sessionCount: number;
archivedBucket: boolean;
} | null>>;
};
/**
* Bulk-action logic for the sidebar. The hot-path concern is that this
* hook subscribes to `useSessionMultiSelectStore` — which can fire on
* every selection toggle and on every setRange/toggleSelected call —
* but the rest of the Sidebar tree only needs the boolean
* `selectionModeEnabled` flag to decide whether to render the
* selection chrome.
*
* To keep that subscription narrow, the heavy work (folders lookup,
* DOM-attribute scanning for the active/archived scope, etc.) is
* deferred behind a `selectedIds.size > 0` check inside the hook
* itself, so toggling selection mode on/off does not force the
* downstream useMemo chain to re-evaluate when no rows are selected.
*/
export const useSidebarBulkActions = (args: Args) => {
const { t } = useI18n();
const {
isInlineEditing,
showDeletionDialog,
foldersMap,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename,
archiveSessions,
deleteSessions,
setBulkDeleteConfirm,
} = args;
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const selectedIdsSize = useSessionMultiSelectStore((state) => state.selectedIds.size);
const hasSelection = selectedIdsSize > 0;
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
const handleToggleSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().toggleMode();
}, []);
const handleExitSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().disable();
}, []);
// All of the below short-circuit on `hasSelection` so the DOM-scanning
// and folder-lookup work only runs when there's something to act on.
const bulkScopeIsArchived = React.useMemo(() => {
if (!hasSelection) return false;
if (typeof document === 'undefined') return false;
let sawActive = false;
let sawArchived = false;
for (const id of selectedIds) {
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
for (const row of rows) {
if (row.getAttribute('data-session-archived') === '1') sawArchived = true;
else sawActive = true;
}
}
return sawArchived && !sawActive;
}, [hasSelection, selectedIds]);
const derivedSelectionScope = React.useMemo(() => {
if (selectionScopeKey) return selectionScopeKey;
if (!hasSelection) return null;
if (typeof document === 'undefined') return null;
for (const id of selectedIds) {
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
const scope = row?.getAttribute('data-session-scope');
if (scope && scope.length > 0) return scope;
}
return null;
}, [hasSelection, selectedIds, selectionScopeKey]);
const bulkScopeFolders = React.useMemo(() => {
if (!derivedSelectionScope) return [];
return foldersMap[derivedSelectionScope] ?? [];
}, [foldersMap, derivedSelectionScope]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!derivedSelectionScope || !hasSelection) return false;
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
for (const folder of scopeFolders) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
return false;
}, [foldersMap, derivedSelectionScope, hasSelection, selectedIds]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!derivedSelectionScope || !hasSelection) return;
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
}, [addSessionsToFolder, selectedIds, derivedSelectionScope, hasSelection]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
if (!derivedSelectionScope || !hasSelection) return;
const newFolder = createFolderAndStartRename(derivedSelectionScope);
if (!newFolder) return;
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope, hasSelection]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!derivedSelectionScope || !hasSelection) return;
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope, hasSelection]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
if (!hasSelection) return;
const count = selectedIds.size;
if (!showDeletionDialog) {
void executeBulkDelete();
return;
}
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog, setBulkDeleteConfirm, hasSelection]);
const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null);
await executeBulkDelete();
// setBulkDeleteConfirm is a stable React state setter; intentionally
// omitted from deps to avoid forcing the keyboard-listener effect
// below to re-subscribe on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [executeBulkDelete]);
React.useEffect(() => {
if (!selectionModeEnabled) return;
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
const listener = (event: KeyboardEvent) => {
if (isInlineEditing) return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
const modifier = isMac ? event.metaKey : event.ctrlKey;
if (event.key === 'Escape') {
event.preventDefault();
useSessionMultiSelectStore.getState().disable();
return;
}
if (modifier && event.key === 'Backspace') {
event.preventDefault();
handleBulkDelete();
return;
}
if (modifier && (event.key === 'a' || event.key === 'A')) {
const rows = typeof document !== 'undefined'
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
: [];
if (rows.length === 0) return;
event.preventDefault();
const currentScope = useSessionMultiSelectStore.getState().scopeKey;
const targetScope = currentScope
?? rows[0]?.getAttribute('data-session-scope')
?? null;
const scopeFilter = (el: HTMLElement): boolean => {
if (!targetScope) return true;
return el.getAttribute('data-session-scope') === targetScope;
};
const ids = rows
.filter(scopeFilter)
.map((el) => el.getAttribute('data-session-row'))
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (ids.length === 0) return;
useSessionMultiSelectStore.getState().replaceAll(ids, targetScope || null);
}
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [handleBulkDelete, isInlineEditing, selectionModeEnabled]);
return {
selectionModeEnabled,
hasSelection,
selectedIdsSize,
bulkScopeIsArchived,
derivedSelectionScope,
bulkScopeFolders,
bulkCanRemoveFromFolder,
handleToggleSelectionMode,
handleExitSelectionMode,
handleBulkMoveToFolder,
handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder,
handleBulkDelete,
confirmBulkDelete,
};
};
@@ -0,0 +1,120 @@
import type { SessionNode } from './types';
/**
* Per-row render extras precomputed once per group render and threaded down to
* each `SessionNodeItem`. Hoisting these out of the row `React.memo` comparator
* turns an O(rows × subtree-depth) walk into per-row `Set.has`/string compares.
*
* The child variant intentionally omits `childRenderExtrasFor` — the resolver is
* shared from the group and re-passed, so it does not need to recurse through
* each child's extras object.
*/
export type SessionNodeChildRenderExtras = {
subtreeContainsActive: Set<string>;
subtreeContainsEditing: Set<string>;
menuOpenSessionId: string | null;
nodeStructureKey: string;
};
export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRenderExtras & {
childRenderExtrasFor?: (child: TNode) => SessionNodeChildRenderExtras;
};
/**
* Walk `nodes` and add `node.session.id` to `result` for every node
* whose subtree contains `targetId`. This is used to precompute, once
* per SessionGroupSection render, which rows need to update when
* `currentSessionId` or `editingId` changes. With M visible rows, this
* turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual`
* into a single O(M) `Set.has` per row.
*/
export const collectSubtreeContainingId = (
nodes: SessionNode[],
targetId: string | null,
result: Set<string>,
): void => {
if (!targetId) return;
const visit = (node: SessionNode): boolean => {
let containsTarget = node.session.id === targetId;
for (const child of node.children) {
containsTarget = visit(child) || containsTarget;
}
if (containsTarget) {
result.add(node.session.id);
}
return containsTarget;
};
for (const node of nodes) {
visit(node);
}
};
export const nodeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => {
if (!sessionId) {
return false;
}
if (node.session.id === sessionId) {
return true;
}
for (const child of node.children) {
if (nodeContainsSessionId(child, sessionId)) {
return true;
}
}
return false;
};
/**
* Build a structural key for `node` that encodes the IDs of all
* descendants. Used by `SessionNodeItem.areEqual` so a reference-only
* rebuild of the tree (which happens on every `buildGroupedSessions`
* pass) can be detected with a single string compare instead of a
* recursive walk per row.
*/
export const computeNodeStructureKey = (node: SessionNode): string => {
if (node.children.length === 0) {
return '';
}
const childKeys = node.children.map((child) => {
if (child.children.length === 0) {
return child.session.id;
}
return `${child.session.id}:${computeNodeStructureKey(child)}`;
});
return childKeys.join('|');
};
/**
* Resolve the session id whose sidebar menu is open, or null if no
* menu is open. Only one row can have its menu open at a time.
*/
export const resolveMenuOpenSessionId = (
nodes: SessionNode[],
menuKey: string | null,
renderContext: 'project' | 'recent',
archivedBucket: boolean,
): string | null => {
if (!menuKey) return null;
const bucketTag = archivedBucket ? 'archived' : 'active';
let result: string | null = null;
const visit = (node: SessionNode): boolean => {
const nodeMenuKey = `${renderContext}:${bucketTag}:${node.session.id}`;
if (nodeMenuKey === menuKey) {
result = node.session.id;
return true;
}
for (const child of node.children) {
if (visit(child)) return true;
}
return false;
};
nodes.forEach((node) => visit(node));
return result;
};
@@ -140,10 +140,11 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
el.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver?.observe(el); resizeObserver?.observe(el);
// checkOverflow mutates our data-scroll attributes; observing attributes
// would make the component trigger its own observer indefinitely.
mutationObserver?.observe(el, { mutationObserver?.observe(el, {
childList: true, childList: true,
subtree: true, subtree: true,
attributes: true,
characterData: true, characterData: true,
}); });
+6
View File
@@ -3,6 +3,12 @@
@import "./styles/design-system.css"; @import "./styles/design-system.css";
@import "./styles/typography.css"; @import "./styles/typography.css";
@import "./styles/mobile.css"; @import "./styles/mobile.css";
/* Load KaTeX styles eagerly with the main bundle. Importing them inside the
lazy MarkdownRendererImpl chunk meant a stylesheet was injected late on the
first markdown render of a session; that late injection triggered a style
recalc during which the layered `.chat-message-column` width clamp briefly
lost, flashing message text to full width before it snapped back. */
@import "katex/dist/katex.min.css";
@source "./**/*.{ts,tsx,css}"; @source "./**/*.{ts,tsx,css}";
/* KaTeX theme overrides for light/dark mode */ /* KaTeX theme overrides for light/dark mode */
@@ -2,6 +2,8 @@ import { create } from 'zustand';
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'; import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { listGlobalSessionPages } from '@/stores/globalSessions'; import { listGlobalSessionPages } from '@/stores/globalSessions';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error'; type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -14,6 +16,7 @@ type GlobalSessionsState = {
activeSessions: Session[]; activeSessions: Session[];
archivedSessions: Session[]; archivedSessions: Session[];
sessionsByDirectory: Map<string, Session[]>; sessionsByDirectory: Map<string, Session[]>;
reviewTransferBySessionId: Map<string, ReviewTransferDirection>;
hasLoaded: boolean; hasLoaded: boolean;
status: GlobalSessionsStatus; status: GlobalSessionsStatus;
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>; loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
@@ -297,11 +300,15 @@ const applySnapshot = (
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
? state.sessionsByDirectory ? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions); : buildSessionsByDirectory(nextActiveSessions);
const nextReviewTransferMap = nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions);
if ( if (
nextActiveSessions === state.activeSessions nextActiveSessions === state.activeSessions
&& nextArchivedSessions === state.archivedSessions && nextArchivedSessions === state.archivedSessions
&& nextSessionsByDirectory === state.sessionsByDirectory && nextSessionsByDirectory === state.sessionsByDirectory
&& nextReviewTransferMap === state.reviewTransferBySessionId
&& state.hasLoaded && state.hasLoaded
&& state.status === status && state.status === status
) { ) {
@@ -312,15 +319,32 @@ const applySnapshot = (
activeSessions: nextActiveSessions, activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions, archivedSessions: nextArchivedSessions,
sessionsByDirectory: nextSessionsByDirectory, sessionsByDirectory: nextSessionsByDirectory,
reviewTransferBySessionId: nextReviewTransferMap,
hasLoaded: true, hasLoaded: true,
status, status,
}; };
}; };
const buildReviewTransferMap = (sessions: Session[]): Map<string, ReviewTransferDirection> => {
const next = new Map<string, ReviewTransferDirection>()
const activeIds = new Set(sessions.map((s) => s.id))
for (const session of sessions) {
const direction = getReviewTransferDirection(session)
if (!direction) continue
const targetSessionId = direction === 'review-to-original'
? getOriginalSessionID(session)
: getReviewSessionID(session)
if (!targetSessionId || !activeIds.has(targetSessionId)) continue
next.set(session.id, direction)
}
return next
}
export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({
activeSessions: [], activeSessions: [],
archivedSessions: [], archivedSessions: [],
sessionsByDirectory: new Map(), sessionsByDirectory: new Map(),
reviewTransferBySessionId: new Map(),
hasLoaded: false, hasLoaded: false,
status: 'idle', status: 'idle',
@@ -424,6 +448,9 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
activeSessions: nextActiveSessions, activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions, archivedSessions: nextArchivedSessions,
sessionsByDirectory: nextSessionsByDirectory, sessionsByDirectory: nextSessionsByDirectory,
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions),
}; };
}); });
@@ -458,6 +485,9 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
sessionsByDirectory: nextActiveSessions === state.activeSessions sessionsByDirectory: nextActiveSessions === state.activeSessions
? state.sessionsByDirectory ? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions), : buildSessionsByDirectory(nextActiveSessions),
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions),
}; };
}); });
}, },
@@ -483,6 +513,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
activeSessions: nextActiveSessions, activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions, archivedSessions: nextArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions), sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
}; };
}); });
}, },
@@ -520,6 +551,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
activeSessions: nextActiveSessions, activeSessions: nextActiveSessions,
archivedSessions: [...movedSessions, ...remainingArchivedSessions], archivedSessions: [...movedSessions, ...remainingArchivedSessions],
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions), sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
}; };
}); });
}, },
+15
View File
@@ -158,3 +158,18 @@
font-weight: var(--markdown-h4-font-weight); font-weight: var(--markdown-h4-font-weight);
} }
} }
/* Chat message column width — intentionally UNLAYERED so it always wins the
cascade. The same rule inside `@layer components` above is correct in steady
state, but a layered rule can be momentarily out-ranked during a heavy style
recalc (e.g. a large session's virtualized list re-laying-out when the
background history prepend lands, or a late-loaded CSS chunk re-establishing
layer order). That dropped the clamp for a frame and flashed message text to
full width. An unlayered duplicate of just the width keeps it robust. */
.chat-message-column {
width: min(100%, 48rem);
}
.wide-chat-layout .chat-message-column {
width: min(100%, 64rem);
}
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2"
import {
areRequestArraysReferentiallyEqual,
collectScopedBlockingRequests,
} from "./scoped-blocking-requests"
const session = (id: string, parentID?: string): Session => ({ id, parentID }) as Session
describe("scoped blocking requests", () => {
test("collects requests for the current session subtree", () => {
const rootRequest = { id: "perm_root" }
const childRequest = { id: "perm_child" }
const grandchildRequest = { id: "perm_grandchild" }
const siblingRequest = { id: "perm_sibling" }
const empty: Array<typeof rootRequest> = []
const result = collectScopedBlockingRequests(
[
session("ses_root"),
session("ses_child", "ses_root"),
session("ses_grandchild", "ses_child"),
session("ses_sibling"),
],
{
ses_root: [rootRequest],
ses_child: [childRequest],
ses_grandchild: [grandchildRequest],
ses_sibling: [siblingRequest],
},
"ses_root",
empty,
)
expect(result).toEqual([rootRequest, childRequest, grandchildRequest])
})
test("returns the provided empty array when no scoped requests exist", () => {
const empty: Array<{ id: string }> = []
expect(collectScopedBlockingRequests([session("ses_root")], {}, "ses_root", empty)).toBe(empty)
expect(collectScopedBlockingRequests([session("ses_root")], {}, null, empty)).toBe(empty)
})
test("compares request arrays by item identity", () => {
const first = { id: "perm_1" }
const second = { id: "perm_2" }
expect(areRequestArraysReferentiallyEqual([first, second], [first, second])).toBe(true)
expect(areRequestArraysReferentiallyEqual([first, second], [second, first])).toBe(false)
expect(areRequestArraysReferentiallyEqual([first], [{ id: "perm_1" }])).toBe(false)
})
})
@@ -0,0 +1,61 @@
import type { Session } from "@opencode-ai/sdk/v2"
type BlockingRequest = { id: string }
export const computeSubtreeIds = (sessions: Session[], rootId: string): Set<string> => {
const childrenByParent = new Map<string, string[]>()
for (const session of sessions) {
if (!session.parentID) continue
const list = childrenByParent.get(session.parentID) ?? []
list.push(session.id)
childrenByParent.set(session.parentID, list)
}
const ids = new Set<string>([rootId])
const queue = [rootId]
for (const id of queue) {
const children = childrenByParent.get(id)
if (!children) continue
for (const childId of children) {
if (ids.has(childId)) continue
ids.add(childId)
queue.push(childId)
}
}
return ids
}
export const areRequestArraysReferentiallyEqual = <T extends BlockingRequest>(left: T[], right: T[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
export const collectScopedBlockingRequests = <T extends BlockingRequest>(
sessions: Session[],
requestsBySession: Record<string, T[] | undefined>,
sessionID: string | null,
empty: T[],
): T[] => {
if (!sessionID) return empty
const scopedIds = computeSubtreeIds(sessions, sessionID)
if (scopedIds.size === 0) return empty
const seen = new Set<string>()
const result: T[] = []
for (const id of scopedIds) {
const entries = requestsBySession[id]
if (!entries) continue
for (const entry of entries) {
if (seen.has(entry.id)) continue
seen.add(entry.id)
result.push(entry)
}
}
return result.length === 0 ? empty : result
}
+73 -2
View File
@@ -13,7 +13,10 @@ import { mergeSessionDirectoryMetadata, useGlobalSessionsStore } from "@/stores/
import { useConfigStore } from "@/stores/useConfigStore" import { useConfigStore } from "@/stores/useConfigStore"
import { registerSessionDirectory } from "./sync-refs" import { registerSessionDirectory } from "./sync-refs"
import { isSyntheticPart } from "@/lib/messages/synthetic" import { isSyntheticPart } from "@/lib/messages/synthetic"
import { materializeSessionSnapshots } from "./materialization" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
import { retry } from "./retry"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { sessionEvents } from "@/lib/sessionEvents" import { sessionEvents } from "@/lib/sessionEvents"
import { import {
@@ -24,7 +27,7 @@ import {
type SessionMetadataRecord, type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata" } from "@/lib/sessionReviewMetadata"
const MESSAGE_REFETCH_LIMIT = 200 const MESSAGE_REFETCH_LIMIT = 100
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const UNREVERT_REFETCH_ATTEMPTS = 3 const UNREVERT_REFETCH_ATTEMPTS = 3
const UNREVERT_REFETCH_RETRY_MS = 150 const UNREVERT_REFETCH_RETRY_MS = 150
@@ -1037,3 +1040,71 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
// Clear existing attachments and restore file parts from the forked message. // Clear existing attachments and restore file parts from the forked message.
restoreFilePartsToInput(fileParts) restoreFilePartsToInput(fileParts)
} }
// ---------------------------------------------------------------------------
// Imperative fetch path — starts message loading on the same tick as
// setCurrentSession, before the React commit cycle fires useEffect.
// ---------------------------------------------------------------------------
const FETCH_MESSAGES_LOADING = new Set<string>()
const DESKTOP_INITIAL_PAGE_SIZE = 50
const CONSTRAINED_INITIAL_PAGE_SIZE = 30
const getFetchPageSize = () => {
if (isVSCodeRuntime() || isMobileSurfaceRuntime()) return CONSTRAINED_INITIAL_PAGE_SIZE
return DESKTOP_INITIAL_PAGE_SIZE
}
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {
const resolvedDir = directory ?? dir()
if (!resolvedDir) return
const s = sdk()
const store = directory
? dirStoreForDirectory(directory)
: dirStore()
if (getSessionMaterializationStatus(store.getState(), sessionID).renderable) return
const loadingKey = `${resolvedDir}:${sessionID}`
if (FETCH_MESSAGES_LOADING.has(loadingKey)) return
FETCH_MESSAGES_LOADING.add(loadingKey)
try {
const result = await retry(async () => {
const response = await s.session.messages({
sessionID,
directory: resolvedDir,
limit: getFetchPageSize(),
})
return response
})
const records = (assertSdkSuccess(result, "session.messages") ?? [])
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
if (records.length === 0) return
// Staleness guard: a rapid session switch may have moved the user off this
// session while the fetch was in flight. Skip the write so a slow fetch
// can't repopulate (and un-evict) a session already navigated away from.
if (useSessionUIStore.getState().currentSessionId !== sessionID) return
store.setState((state) => {
const materialized = materializeSessionSnapshots(
state,
sessionID,
records.map((record: { info: Message; parts?: Part[] }) => ({
info: stripMessageDiffSnapshots(record.info),
parts: record.parts ?? [],
})),
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
)
return { message: materialized.message, part: materialized.part }
})
} catch {
// Transient failure — the reactive path in ChatContainer will retry
} finally {
FETCH_MESSAGES_LOADING.delete(loadingKey)
}
}
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import { buildSessionMessageRecordsSnapshot } from './sync-context';
import { INITIAL_STATE, type State } from './types';
const message = (id: string, role: 'user' | 'assistant', parentID?: string): Message => ({
id,
role,
sessionID: 'ses_1',
...(parentID ? { parentID } : {}),
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): State => ({
...INITIAL_STATE,
...partial,
});
describe('buildSessionMessageRecordsSnapshot', () => {
test('only suspends part updates for the active streaming message', () => {
const user = message('user_1', 'user');
const assistant1 = message('assistant_1', 'assistant', 'user_1');
const assistant2 = message('assistant_2', 'assistant', 'user_1');
const messages = [user, assistant1, assistant2];
const assistant1InitialParts = [textPart('assistant_1_initial', 'initial')];
const assistant2InitialParts = [textPart('assistant_2_initial', 'initial')];
const previous = buildSessionMessageRecordsSnapshot(
state({
message: { ses_1: messages },
part: {
assistant_1: assistant1InitialParts,
assistant_2: assistant2InitialParts,
},
}),
'ses_1',
undefined,
true,
'assistant_1',
);
const assistant1FinalParts = [textPart('assistant_1_final', 'final')];
const assistant2LiveParts = [textPart('assistant_2_live', 'live')];
const next = buildSessionMessageRecordsSnapshot(
state({
message: { ses_1: messages },
part: {
assistant_1: assistant1FinalParts,
assistant_2: assistant2LiveParts,
},
}),
'ses_1',
previous,
true,
'assistant_2',
);
expect(next.byId.get('assistant_1')?.parts).toBe(assistant1FinalParts);
expect(next.byId.get('assistant_2')?.parts).toBe(assistant2InitialParts);
});
});
+8
View File
@@ -51,6 +51,7 @@ import {
revertToMessage as revertToMessageAction, revertToMessage as revertToMessageAction,
unrevertSession as unrevertSessionAction, unrevertSession as unrevertSessionAction,
forkFromMessage as forkFromMessageAction, forkFromMessage as forkFromMessageAction,
fetchMessagesForSession,
} from "./session-actions" } from "./session-actions"
import { useInputStore, type SyntheticContextPart } from "./input-store" import { useInputStore, type SyntheticContextPart } from "./input-store"
import { useSelectionStore } from "./selection-store" import { useSelectionStore } from "./selection-store"
@@ -497,6 +498,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null }) set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null }) writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
// Kick off the message fetch on the same tick, before React commits the
// state change and fires ChatContainer.useEffect. The fetch is
// fire-and-forget — any transient failure gets retried by the reactive path.
if (id) {
void fetchMessagesForSession(id, resolvedDir)
}
try { try {
if (resolvedDir && directoryState.currentDirectory !== resolvedDir) { if (resolvedDir && directoryState.currentDirectory !== resolvedDir) {
directoryState.setDirectory(resolvedDir, { showOverlay: false }) directoryState.setDirectory(resolvedDir, { showOverlay: false })
+146 -39
View File
@@ -23,7 +23,7 @@ import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
import { retry } from "./retry" import { retry } from "./retry"
import { updateStreamingState } from "./streaming" import { updateStreamingState } from "./streaming"
import { setActionRefs } from "./session-actions" import { setActionRefs } from "./session-actions"
import { setSyncRefs } from "./sync-refs" import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { syncDebug } from "./debug" import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds } from "./reconnect-recovery" import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
@@ -47,6 +47,8 @@ import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { setSessionPrefetch } from "./session-prefetch-cache" import { setSessionPrefetch } from "./session-prefetch-cache"
import { listGlobalSessionPages } from "@/stores/globalSessions" import { listGlobalSessionPages } from "@/stores/globalSessions"
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Context // Context
@@ -260,6 +262,7 @@ async function materializeSessionFromServer(
directory: string, directory: string,
sessionID: string, sessionID: string,
store: StoreApi<DirectoryStore>, store: StoreApi<DirectoryStore>,
options?: { isStale?: () => boolean },
) { ) {
const scopedClient = opencodeClient.getScopedSdkClient(directory) const scopedClient = opencodeClient.getScopedSdkClient(directory)
const result = await retry(async () => { const result = await retry(async () => {
@@ -278,6 +281,8 @@ async function materializeSessionFromServer(
complete: !cursor, complete: !cursor,
}) })
if (options?.isStale?.()) return
store.setState((state: DirectoryStore) => { store.setState((state: DirectoryStore) => {
const materialized = materializeSessionSnapshots( const materialized = materializeSessionSnapshots(
state, state,
@@ -2177,6 +2182,77 @@ export function useSessions(directory?: string) {
) )
} }
const selectPermissionRequestsBySession = (state: State) => state.permission
const selectQuestionRequestsBySession = (state: State) => state.question
type ScopedBlockingRequestCache<T extends { id: string }> = {
sessionID: string | null
sessions: Session[] | null
requestsBySession: Record<string, T[] | undefined> | null
result: T[]
}
function useScopedBlockingRequests<T extends { id: string }>(
sessionID: string | null,
directory: string | undefined,
selectRequestsBySession: (state: State) => Record<string, T[] | undefined>,
empty: T[],
): T[] {
const cacheRef = useRef<ScopedBlockingRequestCache<T>>({
sessionID: null,
sessions: null,
requestsBySession: null,
result: empty,
})
return useDirectorySync(
useCallback((state: State) => {
const requestsBySession = selectRequestsBySession(state)
const cache = cacheRef.current
if (
cache.sessionID === sessionID
&& cache.sessions === state.session
&& cache.requestsBySession === requestsBySession
) {
return cache.result
}
const next = collectScopedBlockingRequests(state.session, requestsBySession, sessionID, empty)
const result = areRequestArraysReferentiallyEqual(cache.result, next) ? cache.result : next
cacheRef.current = {
sessionID,
sessions: state.session,
requestsBySession,
result,
}
return result
}, [empty, selectRequestsBySession, sessionID]),
directory,
)
}
export function useScopedBlockingPermissions(sessionID: string | null, directory?: string): PermissionRequest[] {
return useScopedBlockingRequests(sessionID, directory, selectPermissionRequestsBySession, EMPTY_PERMISSION_REQUESTS)
}
export function useScopedBlockingQuestions(sessionID: string | null, directory?: string): QuestionRequest[] {
return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS)
}
export function useParentSession(sessionID: string | null, directory?: string): Session | null {
return useDirectorySync(
useCallback((state: State) => {
if (!sessionID) return null
const current = state.session.find((s) => s.id === sessionID)
if (!current?.parentID) return null
return state.session.find((s) => s.id === current.parentID)
?? getAllSyncSessions().find((s) => s.id === current.parentID)
?? null
}, [sessionID]),
directory,
)
}
const getSidebarSessionSignature = (session: Session, stableUpdatedAt: number): string => { const getSidebarSessionSignature = (session: Session, stableUpdatedAt: number): string => {
const directory = (session as Session & { directory?: string | null }).directory ?? '' const directory = (session as Session & { directory?: string | null }).directory ?? ''
const parentID = (session as Session & { parentID?: string | null }).parentID ?? '' const parentID = (session as Session & { parentID?: string | null }).parentID ?? ''
@@ -2348,14 +2424,6 @@ const getConcatenatedTextFromParts = (parts: Part[]): string => {
return text return text
} }
const getFirstTextFromParts = (parts: Part[]): string => {
for (const part of parts) {
const text = getPartText(part)
if (text.length > 0) return text
}
return ""
}
type SessionMessageRecord = { info: Message; parts: Part[] } type SessionMessageRecord = { info: Message; parts: Part[] }
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = [] const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
@@ -2365,6 +2433,7 @@ type SessionMessageRecordsSnapshot = {
visibleMessages: Message[] visibleMessages: Message[]
revertMessageID?: string revertMessageID?: string
suspendPartUpdates: boolean suspendPartUpdates: boolean
suspendedPartUpdatesMessageID?: string
list: SessionMessageRecord[] list: SessionMessageRecord[]
byId: Map<string, SessionMessageRecord> byId: Map<string, SessionMessageRecord>
} }
@@ -2376,8 +2445,12 @@ const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30 const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30
const sessionMessageRecordsCache = new WeakMap<StoreApi<DirectoryStore>, Map<string, SessionMessageRecordsSnapshot>>() const sessionMessageRecordsCache = new WeakMap<StoreApi<DirectoryStore>, Map<string, SessionMessageRecordsSnapshot>>()
const getSessionMessageRecordsCacheKey = (sessionID: string, suspendPartUpdates: boolean): string => ( const getSessionMessageRecordsCacheKey = (
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}` sessionID: string,
suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): string => (
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}\u0000${suspendedPartUpdatesMessageID ?? ""}`
) )
const getSessionMessageRecordsCache = (store: StoreApi<DirectoryStore>): Map<string, SessionMessageRecordsSnapshot> => { const getSessionMessageRecordsCache = (store: StoreApi<DirectoryStore>): Map<string, SessionMessageRecordsSnapshot> => {
@@ -2393,10 +2466,11 @@ const readCachedSessionMessageRecordsSnapshot = (
store: StoreApi<DirectoryStore>, store: StoreApi<DirectoryStore>,
sessionID: string, sessionID: string,
suspendPartUpdates: boolean, suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot | undefined => { ): SessionMessageRecordsSnapshot | undefined => {
const cache = sessionMessageRecordsCache.get(store) const cache = sessionMessageRecordsCache.get(store)
if (!cache) return undefined if (!cache) return undefined
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates) const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
const cached = cache.get(key) const cached = cache.get(key)
if (!cached) return undefined if (!cached) return undefined
cache.delete(key) cache.delete(key)
@@ -2410,7 +2484,11 @@ const rememberSessionMessageRecordsSnapshot = (
): void => { ): void => {
if (!snapshot.sessionID) return if (!snapshot.sessionID) return
const cache = getSessionMessageRecordsCache(store) const cache = getSessionMessageRecordsCache(store)
const key = getSessionMessageRecordsCacheKey(snapshot.sessionID, snapshot.suspendPartUpdates) const key = getSessionMessageRecordsCacheKey(
snapshot.sessionID,
snapshot.suspendPartUpdates,
snapshot.suspendedPartUpdatesMessageID,
)
const constrainedMaxMessages = isVSCodeRuntime() const constrainedMaxMessages = isVSCodeRuntime()
? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES ? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES
: isMobileSurfaceRuntime() : isMobileSurfaceRuntime()
@@ -2442,17 +2520,23 @@ export function dropCachedSessionMessageRecordsSnapshots(
if (!cache) return if (!cache) return
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
if (!sessionID) continue if (!sessionID) continue
cache.delete(getSessionMessageRecordsCacheKey(sessionID, false)) const prefix = `${sessionID}\u0000`
cache.delete(getSessionMessageRecordsCacheKey(sessionID, true)) for (const key of [...cache.keys()]) {
if (key.startsWith(prefix)) {
cache.delete(key)
}
}
} }
} }
const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => { const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => {
if (snapshot.suspendPartUpdates) {
return true
}
for (const record of snapshot.list) { for (const record of snapshot.list) {
if (snapshot.suspendPartUpdates) {
const suspendedID = snapshot.suspendedPartUpdatesMessageID
if (!suspendedID || record.info.id === suspendedID) {
continue
}
}
if ((state.part[record.info.id] ?? EMPTY_PARTS) !== record.parts) { if ((state.part[record.info.id] ?? EMPTY_PARTS) !== record.parts) {
return false return false
} }
@@ -2466,8 +2550,9 @@ const getReusableSessionMessageRecordsSnapshot = (
state: State, state: State,
sessionID: string, sessionID: string,
suspendPartUpdates: boolean, suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot | undefined => { ): SessionMessageRecordsSnapshot | undefined => {
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates) const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
if (!cached) return undefined if (!cached) return undefined
const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES
const session = state.session.find((candidate) => candidate.id === sessionID) const session = state.session.find((candidate) => candidate.id === sessionID)
@@ -2476,6 +2561,7 @@ const getReusableSessionMessageRecordsSnapshot = (
cached.sourceMessages === sourceMessages cached.sourceMessages === sourceMessages
&& cached.revertMessageID === revertMessageID && cached.revertMessageID === revertMessageID
&& cached.suspendPartUpdates === suspendPartUpdates && cached.suspendPartUpdates === suspendPartUpdates
&& cached.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID
&& snapshotPartsMatchState(cached, state) && snapshotPartsMatchState(cached, state)
) { ) {
return cached return cached
@@ -2516,12 +2602,16 @@ export function buildSessionMessageRecordsSnapshot(
sessionID: string, sessionID: string,
previous?: SessionMessageRecordsSnapshot, previous?: SessionMessageRecordsSnapshot,
suspendPartUpdates = false, suspendPartUpdates = false,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot { ): SessionMessageRecordsSnapshot {
const { sourceMessages, visibleMessages, revertMessageID } = getVisibleMessagesForSession(state, sessionID, previous) const { sourceMessages, visibleMessages, revertMessageID } = getVisibleMessagesForSession(state, sessionID, previous)
const nextById = new Map<string, SessionMessageRecord>() const nextById = new Map<string, SessionMessageRecord>()
const nextList = visibleMessages.map((message) => { const nextList = visibleMessages.map((message) => {
const previousRecord = previous?.byId.get(message.id) const previousRecord = previous?.byId.get(message.id)
const parts = suspendPartUpdates && previousRecord const shouldSuspendParts = suspendPartUpdates
&& previousRecord
&& (!suspendedPartUpdatesMessageID || message.id === suspendedPartUpdatesMessageID)
const parts = shouldSuspendParts
? previousRecord.parts ? previousRecord.parts
: (state.part[message.id] ?? EMPTY_PARTS) : (state.part[message.id] ?? EMPTY_PARTS)
@@ -2535,6 +2625,8 @@ export function buildSessionMessageRecordsSnapshot(
const unchanged = Boolean(previous) const unchanged = Boolean(previous)
&& previous?.visibleMessages === visibleMessages && previous?.visibleMessages === visibleMessages
&& previous.suspendPartUpdates === suspendPartUpdates
&& previous.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID
&& previous.list.length === nextList.length && previous.list.length === nextList.length
&& previous.list.every((record, index) => record === nextList[index]) && previous.list.every((record, index) => record === nextList[index])
@@ -2548,6 +2640,7 @@ export function buildSessionMessageRecordsSnapshot(
visibleMessages, visibleMessages,
revertMessageID, revertMessageID,
suspendPartUpdates, suspendPartUpdates,
suspendedPartUpdatesMessageID,
list: nextList, list: nextList,
byId: nextById, byId: nextById,
} }
@@ -2577,20 +2670,21 @@ export function useSessionTextMessages(sessionID: string, directory?: string): S
} }
export function useUserMessageHistory(sessionID: string, directory?: string): string[] { export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
const records = useSessionMessageRecords(sessionID, directory) const store = useDirectoryStore(directory)
const userMessages = useMemo(() => records.filter((record) => record.info.role === 'user'), [records]) const snapshotRef = useRef<UserMessageHistorySnapshot>(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT)
return useMemo(() => { const getSnapshot = useCallback(() => {
const history: string[] = [] const next = buildUserMessageHistorySnapshot(store.getState(), sessionID, snapshotRef.current)
for (let index = userMessages.length - 1; index >= 0; index -= 1) { snapshotRef.current = next
const message = userMessages[index] return next.history
const text = getFirstTextFromParts(message.parts) }, [sessionID, store])
if (text.length > 0) {
history.push(text) const subscribe = useCallback((notify: () => void) => {
} if (!sessionID) return () => undefined
} return store.subscribe(notify)
return history }, [sessionID, store])
}, [userMessages])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
} }
/** /**
@@ -2603,7 +2697,7 @@ export function useUserMessageHistory(sessionID: string, directory?: string): st
export function useSessionMessageRecords( export function useSessionMessageRecords(
sessionID: string, sessionID: string,
directory?: string, directory?: string,
options?: { suspendPartUpdates?: boolean }, options?: { suspendPartUpdates?: boolean; suspendPartUpdatesForMessageId?: string | null },
) { ) {
const store = useDirectoryStore(directory) const store = useDirectoryStore(directory)
const snapshotRef = useRef<SessionMessageRecordsSnapshot>({ const snapshotRef = useRef<SessionMessageRecordsSnapshot>({
@@ -2612,6 +2706,7 @@ export function useSessionMessageRecords(
visibleMessages: EMPTY_MESSAGES, visibleMessages: EMPTY_MESSAGES,
revertMessageID: undefined, revertMessageID: undefined,
suspendPartUpdates: Boolean(options?.suspendPartUpdates), suspendPartUpdates: Boolean(options?.suspendPartUpdates),
suspendedPartUpdatesMessageID: options?.suspendPartUpdatesForMessageId ?? undefined,
list: [], list: [],
byId: new Map(), byId: new Map(),
}) })
@@ -2623,7 +2718,14 @@ export function useSessionMessageRecords(
const state = store.getState() const state = store.getState()
const suspendPartUpdates = Boolean(options?.suspendPartUpdates) const suspendPartUpdates = Boolean(options?.suspendPartUpdates)
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(store, state, sessionID, suspendPartUpdates) const suspendedPartUpdatesMessageID = options?.suspendPartUpdatesForMessageId ?? undefined
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(
store,
state,
sessionID,
suspendPartUpdates,
suspendedPartUpdatesMessageID,
)
if (reusableSnapshot) { if (reusableSnapshot) {
snapshotRef.current = reusableSnapshot snapshotRef.current = reusableSnapshot
return reusableSnapshot.list return reusableSnapshot.list
@@ -2631,18 +2733,19 @@ export function useSessionMessageRecords(
const previousSnapshot = snapshotRef.current.sessionID === sessionID const previousSnapshot = snapshotRef.current.sessionID === sessionID
? snapshotRef.current ? snapshotRef.current
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates) : readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
const nextSnapshot = buildSessionMessageRecordsSnapshot( const nextSnapshot = buildSessionMessageRecordsSnapshot(
state, state,
sessionID, sessionID,
previousSnapshot, previousSnapshot,
suspendPartUpdates, suspendPartUpdates,
suspendedPartUpdatesMessageID,
) )
snapshotRef.current = nextSnapshot snapshotRef.current = nextSnapshot
rememberSessionMessageRecordsSnapshot(store, nextSnapshot) rememberSessionMessageRecordsSnapshot(store, nextSnapshot)
return nextSnapshot.list return nextSnapshot.list
}, [options?.suspendPartUpdates, sessionID, store]) }, [options?.suspendPartUpdates, options?.suspendPartUpdatesForMessageId, sessionID, store])
const subscribe = useCallback((notify: () => void) => { const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined if (!sessionID) return () => undefined
@@ -2671,6 +2774,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
const syncDirectory = useSyncDirectory() const syncDirectory = useSyncDirectory()
const resolvedDirectory = directory ?? syncDirectory const resolvedDirectory = directory ?? syncDirectory
const store = useDirectoryStore(resolvedDirectory) const store = useDirectoryStore(resolvedDirectory)
const requestGenerationRef = React.useRef(0)
React.useEffect(() => { React.useEffect(() => {
if (!sessionID) return if (!sessionID) return
@@ -2685,11 +2789,14 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
// Already loading this session for this directory // Already loading this session for this directory
if (_ensureMessagesLoading.has(loadingKey)) return if (_ensureMessagesLoading.has(loadingKey)) return
const generation = ++requestGenerationRef.current
const isStale = () => generation !== requestGenerationRef.current
_ensureMessagesLoading.add(loadingKey) _ensureMessagesLoading.add(loadingKey)
void (async () => { void (async () => {
try { try {
await materializeSessionFromServer(resolvedDirectory, sessionID, store) await materializeSessionFromServer(resolvedDirectory, sessionID, store, { isStale })
} catch { } catch {
// Transient failure — next navigation or reconnect will retry // Transient failure — next navigation or reconnect will retry
} finally { } finally {
+52 -10
View File
@@ -22,10 +22,11 @@ import {
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const INITIAL_MESSAGE_PAGE_SIZE = 150 const INITIAL_MESSAGE_PAGE_SIZE = 50
const VSCODE_INITIAL_MESSAGE_PAGE_SIZE = 30 const VSCODE_INITIAL_MESSAGE_PAGE_SIZE = 30
const MOBILE_INITIAL_MESSAGE_PAGE_SIZE = 30 const MOBILE_INITIAL_MESSAGE_PAGE_SIZE = 30
const HISTORY_MESSAGE_PAGE_SIZE = 200 const HISTORY_MESSAGE_PAGE_SIZE = 100
const INITIAL_PAGE_EXPANSION_LIMITS = [100, 150] as const
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
const MAX_SEEN_DIRS = 30 const MAX_SEEN_DIRS = 30
const VSCODE_SESSION_CACHE_LIMIT = 4 const VSCODE_SESSION_CACHE_LIMIT = 4
@@ -40,6 +41,12 @@ const seenByDirectory = new Map<string, Set<string>>()
// all request the same session during startup; coalesce them into one HTTP load. // all request the same session during startup; coalesce them into one HTTP load.
const syncSessionInflightByKey = new Map<string, Promise<void>>() const syncSessionInflightByKey = new Map<string, Promise<void>>()
// Per-session generation counter. When a newer syncSession request starts for
// the same session, older in-flight requests become stale and must not write
// to the store. This prevents rapid session switches (e.g. 1→2→3 in the
// sidebar) from having each completed fetch fight for focus.
const syncSessionGenerationByKey = new Map<string, number>()
type SyncMeta = { type SyncMeta = {
limit: number limit: number
cursor: string | undefined cursor: string | undefined
@@ -88,6 +95,9 @@ const getInitialMessagePageSize = () => {
if (isMobileSurfaceRuntime()) return MOBILE_INITIAL_MESSAGE_PAGE_SIZE if (isMobileSurfaceRuntime()) return MOBILE_INITIAL_MESSAGE_PAGE_SIZE
return INITIAL_MESSAGE_PAGE_SIZE return INITIAL_MESSAGE_PAGE_SIZE
} }
const getInitialPageExpansionLimits = () => isConstrainedSessionRuntime()
? VSCODE_INITIAL_PAGE_EXPANSION_LIMITS
: INITIAL_PAGE_EXPANSION_LIMITS
const getDefaultMeta = (): SyncMeta => ({ limit: getInitialMessagePageSize(), cursor: undefined, complete: false, loading: false }) const getDefaultMeta = (): SyncMeta => ({ limit: getInitialMessagePageSize(), cursor: undefined, complete: false, loading: false })
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined { function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
@@ -333,7 +343,7 @@ export function useSync() {
// Load messages for a session // Load messages for a session
const loadMessages = useCallback( const loadMessages = useCallback(
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend" }) => { async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend"; isStale?: () => boolean }) => {
const m = getMetaFor(sessionID) const m = getMetaFor(sessionID)
if (m.loading) return if (m.loading) return
setMetaFor(sessionID, { loading: true }) setMetaFor(sessionID, { loading: true })
@@ -342,13 +352,13 @@ export function useSync() {
const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : m.limit const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : m.limit
let page = await fetchMessages(sessionID, limit, options?.before) let page = await fetchMessages(sessionID, limit, options?.before)
// Constrained shells keep the initial page small for switch performance. Some // Keep the initial page small for switch performance. Some sessions
// sessions have a very large final turn, so the latest 30 records can // have a very large final turn, so the latest records can
// contain only assistant/tool records and no user boundary. That makes // contain only assistant/tool records and no user boundary. That makes
// turn projection render an empty chat until the user manually loads // turn projection render an empty chat until the user manually loads
// older messages. Expand only this initial tail fetch, with a hard cap. // older messages. Expand only this initial tail fetch, with a hard cap.
if (!options?.before && isConstrainedSessionRuntime() && !page.complete && !hasUserMessage(page.session)) { if (!options?.before && !page.complete && !hasUserMessage(page.session)) {
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) { for (const nextLimit of getInitialPageExpansionLimits()) {
if (nextLimit <= limit) continue if (nextLimit <= limit) continue
page = await fetchMessages(sessionID, nextLimit) page = await fetchMessages(sessionID, nextLimit)
if (page.complete || hasUserMessage(page.session)) break if (page.complete || hasUserMessage(page.session)) break
@@ -362,6 +372,11 @@ export function useSync() {
clearOptimistic(sessionID, messageID) clearOptimistic(sessionID, messageID)
} }
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
const current = store.getState() const current = store.getState()
const materialized = materializeSessionSnapshots( const materialized = materializeSessionSnapshots(
current, current,
@@ -373,6 +388,11 @@ export function useSync() {
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" }, { skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
) )
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
setMetaFor(sessionID, { setMetaFor(sessionID, {
limit: materialized.messages.length, limit: materialized.messages.length,
cursor: merged.cursor, cursor: merged.cursor,
@@ -404,6 +424,13 @@ export function useSync() {
const existing = syncSessionInflightByKey.get(key) const existing = syncSessionInflightByKey.get(key)
if (existing) return existing if (existing) return existing
// This is a new request. Bump generation so any older request that
// might still be finishing (e.g. from a previous component lifecycle)
// knows it is stale and should not write to the store.
const generation = (syncSessionGenerationByKey.get(key) ?? 0) + 1
syncSessionGenerationByKey.set(key, generation)
const isStale = () => syncSessionGenerationByKey.get(key) !== generation
const current = store.getState() const current = store.getState()
const m = getMetaFor(sessionID) const m = getMetaFor(sessionID)
const materialization = getSessionMaterializationStatus(current, sessionID) const materialization = getSessionMaterializationStatus(current, sessionID)
@@ -449,7 +476,7 @@ export function useSync() {
assertSdkSuccess(response, "session.get") assertSdkSuccess(response, "session.get")
return response return response
}) })
if (result.data) { if (result.data && !isStale()) {
const nextSession = stripSessionDiffSnapshots(result.data) const nextSession = stripSessionDiffSnapshots(result.data)
const s = store.getState() const s = store.getState()
const sessions = [...s.session] const sessions = [...s.session]
@@ -459,15 +486,30 @@ export function useSync() {
} else { } else {
sessions.splice(idx.index, 0, nextSession) sessions.splice(idx.index, 0, nextSession)
} }
store.setState({ session: sessions }) if (!isStale()) {
store.setState({ session: sessions })
}
} }
} catch (e) { } catch (e) {
console.error("[sync] failed to fetch session", sessionID, e) console.error("[sync] failed to fetch session", sessionID, e)
} }
})() })()
: Promise.resolve(), : Promise.resolve(),
shouldLoadMessages ? loadMessages(sessionID) : Promise.resolve(), shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
]) ])
// Progressive mount: after the initial page resolves, if the session
// isn't stale and the server indicated more messages, dispatch a
// second fetch to prepend older history. The user sees the first page
// immediately; the rest arrive shortly after. This gives the scroll
// container headroom above the viewport so the "load older on
// scroll-up" trigger fires before the user hits the absolute top.
if (!isStale()) {
const currentMeta = getMetaFor(sessionID)
if (currentMeta.cursor && !currentMeta.complete) {
loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })
}
}
})() })()
syncSessionInflightByKey.set(key, promise) syncSessionInflightByKey.set(key, promise)
@@ -0,0 +1,98 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from './types';
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot } from './user-message-history';
const message = (id: string, role: 'user' | 'assistant'): Message => ({
id,
role,
sessionID: 'ses_1',
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): Pick<State, 'session' | 'message' | 'part'> => ({
session: [],
message: {},
part: {},
...partial,
});
describe('buildUserMessageHistorySnapshot', () => {
test('returns a shared empty snapshot without a session id', () => {
expect(buildUserMessageHistorySnapshot(state({}), '')).toBe(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT);
});
test('keeps history stable when assistant parts change', () => {
const user = message('user_1', 'user');
const assistant = message('assistant_1', 'assistant');
const userParts = [textPart('part_user', 'hello')];
const first = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user, assistant] },
part: { user_1: userParts, assistant_1: [textPart('part_a', 'stream')] },
}),
'ses_1',
);
const second = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user, assistant] },
part: { user_1: userParts, assistant_1: [textPart('part_a2', 'streaming')] },
}),
'ses_1',
first,
);
expect(second).toBe(first);
expect(second.history).toEqual(['hello']);
});
test('updates history when a user part changes', () => {
const user = message('user_1', 'user');
const first = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user] },
part: { user_1: [textPart('part_user', 'hello')] },
}),
'ses_1',
);
const second = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user] },
part: { user_1: [textPart('part_user_updated', 'updated')] },
}),
'ses_1',
first,
);
expect(second).not.toBe(first);
expect(second.history).toEqual(['updated']);
});
test('excludes user messages hidden by session revert state', () => {
const beforeRevert = message('user_1', 'user');
const reverted = message('user_2', 'user');
const snapshot = buildUserMessageHistorySnapshot(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_2' } } as State['session'][number]],
message: { ses_1: [beforeRevert, reverted] },
part: {
user_1: [textPart('part_user_1', 'kept')],
user_2: [textPart('part_user_2', 'reverted')],
},
}),
'ses_1',
);
expect(snapshot.history).toEqual(['kept']);
});
});
@@ -0,0 +1,98 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from './types';
type UserMessageHistoryRecord = {
message: Message;
parts: Part[];
};
export type UserMessageHistorySnapshot = {
sessionID: string;
revertMessageID?: string;
records: UserMessageHistoryRecord[];
history: string[];
};
const EMPTY_PARTS: Part[] = [];
const EMPTY_RECORDS: UserMessageHistoryRecord[] = [];
const EMPTY_HISTORY: string[] = [];
export const EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT: UserMessageHistorySnapshot = {
sessionID: '',
revertMessageID: undefined,
records: EMPTY_RECORDS,
history: EMPTY_HISTORY,
};
const getPartText = (part: Part): string => {
if (part?.type !== 'text') return '';
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? text : '';
};
const getFirstTextFromParts = (parts: Part[]): string => {
for (const part of parts) {
const text = getPartText(part);
if (text.length > 0) return text;
}
return '';
};
const areRecordsEqual = (left: UserMessageHistoryRecord[], right: UserMessageHistoryRecord[]): boolean => {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) {
return false;
}
}
return true;
};
export const buildUserMessageHistorySnapshot = (
state: Pick<State, 'session' | 'message' | 'part'>,
sessionID: string,
previous: UserMessageHistorySnapshot = EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT,
): UserMessageHistorySnapshot => {
if (!sessionID) {
return EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT;
}
const messages = state.message[sessionID] ?? [];
const session = state.session.find((candidate) => candidate.id === sessionID);
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
const records: UserMessageHistoryRecord[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== 'user') {
continue;
}
if (revertMessageID && message.id >= revertMessageID) {
continue;
}
records.push({
message,
parts: state.part[message.id] ?? EMPTY_PARTS,
});
}
if (records.length === 0) {
return previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && previous.records.length === 0
? previous
: { sessionID, revertMessageID, records: EMPTY_RECORDS, history: EMPTY_HISTORY };
}
if (previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, records)) {
return previous;
}
const history: string[] = [];
for (const record of records) {
const text = getFirstTextFromParts(record.parts);
if (text.length > 0) {
history.push(text);
}
}
return { sessionID, revertMessageID, records, history };
};