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:
@@ -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.
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
|
|
||||||
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')
|
||||||
|
|||||||
+6
-3
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -326,12 +331,12 @@ type UiNotificationPayload = {
|
|||||||
body?: unknown
|
body?: unknown
|
||||||
tag?: unknown
|
tag?: unknown
|
||||||
kind?: unknown
|
kind?: unknown
|
||||||
sessionId?: unknown
|
sessionId?: unknown
|
||||||
directory?: unknown
|
directory?: unknown
|
||||||
requireHidden?: unknown
|
requireHidden?: unknown
|
||||||
desktopNotificationDelivered?: unknown
|
desktopNotificationDelivered?: unknown
|
||||||
desktopStdoutActive?: unknown
|
desktopStdoutActive?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
const asOptionalString = (value: unknown): string | undefined => {
|
const asOptionalString = (value: unknown): string | undefined => {
|
||||||
if (typeof value !== "string") return undefined
|
if (typeof value !== "string") return undefined
|
||||||
@@ -350,9 +355,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notification = properties as UiNotificationPayload
|
const notification = properties as UiNotificationPayload
|
||||||
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
|
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifications = getRegisteredRuntimeAPIs()?.notifications
|
const notifications = getRegisteredRuntimeAPIs()?.notifications
|
||||||
if (!notifications?.notifyAgentCompletion) {
|
if (!notifications?.notifyAgentCompletion) {
|
||||||
@@ -1657,10 +1662,10 @@ export function SyncProvider(props: {
|
|||||||
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
config: globalState.config,
|
config: globalState.config,
|
||||||
projects: globalState.projects,
|
projects: globalState.projects,
|
||||||
},
|
},
|
||||||
loadSessions: (dir) => retry(async () => {
|
loadSessions: (dir) => retry(async () => {
|
||||||
const rootSessions = (await listGlobalSessionPages(props.sdk, {
|
const rootSessions = (await listGlobalSessionPages(props.sdk, {
|
||||||
directory: dir,
|
directory: dir,
|
||||||
@@ -1751,11 +1756,11 @@ export function SyncProvider(props: {
|
|||||||
const generation = ++globalBootstrapGeneration
|
const generation = ++globalBootstrapGeneration
|
||||||
bootingRoot = true
|
bootingRoot = true
|
||||||
const globalActions = useGlobalSyncStore.getState().actions
|
const globalActions = useGlobalSyncStore.getState().actions
|
||||||
bootstrapGlobal(props.sdk, (patch) => {
|
bootstrapGlobal(props.sdk, (patch) => {
|
||||||
if (globalBootstrapGeneration === generation) {
|
if (globalBootstrapGeneration === generation) {
|
||||||
globalActions.set(patch)
|
globalActions.set(patch)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (globalBootstrapGeneration === generation) {
|
if (globalBootstrapGeneration === generation) {
|
||||||
bootedAt = Date.now()
|
bootedAt = Date.now()
|
||||||
@@ -1771,7 +1776,7 @@ export function SyncProvider(props: {
|
|||||||
bootingRoot = false
|
bootingRoot = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [props.sdk])
|
}, [props.sdk])
|
||||||
|
|
||||||
// Event pipeline — created once per mount. No class, no start/stop.
|
// Event pipeline — created once per mount. No class, no start/stop.
|
||||||
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user