perf: harden sync architecture and modularize runtimes (#803)

* fix: added desktop app background throttling

* perf: add streaming debug metrics panel

- Show streaming performance metrics in the debug panel
- Auto-enable stream profiling while the panel is open
- Add JSON export for sharing UI and VS Code metrics

* perf: batch streaming updates more aggressively

- Buffer message deltas and metadata updates to cut render churn
- Skip no-op part updates before they touch the message store
- Fix the desktop debug panel shortcut binding

* perf: split streaming event handling and coalesce deltas

- Move streaming content events onto a dedicated fast path
- Defer non-critical stream side effects off the hot path
- Merge repeated message delta events before they reach the UI

* perf: isolate streaming rows from chat rerenders

- Memoize chat rows against render-relevant message changes only
- Read live assistant text directly from store to narrow streaming updates
- Split the active streaming entry from the stable message list path

* perf: streamline chat streaming and SSE proxying

- Reduce chat rerenders around the active streaming path
- Simplify server SSE forwarding to avoid duplicate proxy work

* fix: preserve the first streaming text chunk

- Show the initial text chunk immediately before batched deltas arrive
- Bypass batching for the first text or reasoning part update
- Keep later streaming updates buffered for performance

* perf: align streaming/render hot paths with opencode parity

* perf: harden turn/cache stability and stale delta suppression

* fix: stabilize chat rendering and disable timeline interactions

- Disabled timeline dialog access from shortcuts, commands, and chat input
- Reduced chat render churn by simplifying message list and turn staging behavior
- Improved session-switch stability to prevent update-depth crashes

* perf: track static message rerenders during streaming

* perf: reduce sorted-mode activity rerender fanout

* perf: reduce chat rerender fanout and add active-turn metrics

- Reduced sorted-mode rerender coupling by tightening turn context propagation
- Added a metric for static rerenders outside the active turn during streaming
- Exposed new chat render counters in the debug panel for parity tracking

* fix: keep sorted activity mounted while stream grows

* fix: stabilize session and history scroll rendering

* refactor: decouple server routes from index

* refactor: extract fs module from server index

* refactor: move opencode route ownership into module

* refactor: extract notification route registration

* refactor: extract opencode and notification runtimes from index

* refactor: extract settings runtime and complete server modularization pass

* refactor: modularize server config, skills, icons, and tunnel routes

* refactor: extract server modules from monolithic index.js

Split proxy, routes, runtime helpers, and notification emitter
into dedicated modules under packages/web/server/lib/.

* refactor: replace session/message stores with SSE-driven sync layer

Delete ~9200 lines of old architecture (useEventStream, messageStore,
sessionStore, useSessionStore, questionStore, useTodoStore, client SSE).

New sync layer: event pipeline with coalescing + 16ms flush, pure event
reducer, per-directory child stores with LRU eviction, cursor pagination,
optimistic updates, deferred timeline staging, text throttle.

Migrate all UI consumers to sync hooks (useSessionMessages,
useSessionMessageRecords, useSessionStatus, useSessionPermissions, etc).

Strip session-ui-store to UI-only state, delegate SDK ops to
session-actions with abort-if-busy, optimistic store updates, and
response merging for revert/fork/archive/delete.

Add notification-store for SSE-driven session attention tracking,
cross-directory GlobalSessionStatusStore for sidebar indicators,
client-side diff snapshot sanitization to prevent memory bloat,
and revert message filtering via useVisibleSessionMessages.

* feat: notification store, session actions, activity detection

Add notification-store.ts for SSE-driven attention tracking.
Add sanitize.ts to strip diff snapshot memory bloat.
Add session-actions.ts with optimistic revert/fork/archive/delete.
Improve useSessionActivity with incomplete-message fallback.
Delete useServerSessionStatus polling hook.

* fix: add directory param to all SDK calls, fix command/shell/abort routing

All SDK calls in session-actions.ts now pass directory parameter —
required by OpenCode server to scope session operations. Without it,
abort, commands, revert, fork, and other operations returned 500.

Add routeMessage() in session-ui-store for shell mode (session.shell),
slash commands (session.command), and normal prompts. Command lookup
checks both sync child store and useCommandsStore. Handle /compact
locally via session.summarize().

Implement getContextUsage() to restore header context usage display —
reads token counts from last assistant message in sync store.

* refactor: replace custom API proxy with http-proxy-middleware

Remove ~280 lines of custom proxy code: forwardSseRequest,
forwardGenericApiRequest, collectRequestBodyBuffer, header
manipulation, hop-by-hop filtering, SSE block buffering.

Replace with single createProxyMiddleware() call that handles
SSE streaming, large bodies, and timeouts out of the box.
Dynamic router for OpenCode port changes after restarts.
Auth headers injected via proxyReq hook.

Keep: readiness gate, Windows session merge, API prefix detection.

* perf: targeted event draft cloning to fix streaming render cascade

Event handler was eagerly cloning all state slices on every event,
breaking Zustand selector referential equality. During streaming
(~60 events/sec), this caused every subscriber to re-render regardless
of which slice actually changed.

Now only clones fields the specific event type mutates. Also extracts
StatusRowContainer to isolate high-frequency useAssistantStatus
subscription, removes dead messageStreamStatesMap subscription from
ChatContainer, and narrows useAssistantStatus to only track last
assistant message parts.

MessageList renders: 1972 → 296 per streaming session (-85%).

* fix: null safety for sync state slices

Add defensive ?? {} guards on permission, question, session_status,
and message record access. Prevents crashes when child store state
is partially initialized during bootstrap.

* perf: dedup inflight SDK calls, extract concurrency util, delay PR tracking

Extract mapWithConcurrency to shared lib/concurrency.ts. Add in-flight
dedup for loadProviders/loadAgents to prevent concurrent duplicate SDK
calls. Delay initial PR background tracking by 5s to reduce startup
CPU burst.

* fix: header session lookup across all child stores

Session title and context panel click failed when session belonged to
a different directory than the current child store. Fall back to
getAllSyncSessions() to search all initialized stores.

* chore: bump @opencode-ai/sdk to 1.3.5

* docs: add sync event handling guide

* Optimize session prefetch and improve delete/archive UX

- Add settlement delay to session prefetch to avoid race conditions on
  rapid session switches
- Reduce git diff prefetch and session cache limits for better performance
- Implement optimistic UI updates for session delete/archive operations
  with proper rollback on failure
- Wire session prefetch hook into SessionSidebar with sync integration

* Add file content cache and sync optimizations

- Wrap FilesAPI with in-memory LRU cache for file content with dual
  constraints (entry count and byte size)
- Optimize chat timeline scroll restoration using useLayoutEffect
- Preserve React references in message and part arrays to prevent
  unnecessary re-renders when prepending history
- Add session prefetch TTL cache to prevent redundant fetches
- Integrate session prefetch cache clearing with eviction flow

* Improve session sidebar error handling and add diff prefetch filtering

Load active and archived sessions independently using Promise.allSettled
to prevent one failure from blocking the other. Add retry logic to session
API calls and skip large files during diff prefetch to improve performance.

* Replace sendMessage with optimisticSend wrapper

Introduces optimistic UI updates for normal chat messages to provide
instant feedback. Messages appear immediately in the UI while the API
call executes in the background, with automatic rollback on errors.

* perf: split stores, proper optimistic send, fix revert/directory bugs

- split session-ui-store into voice/input/selection/viewport stores
  to reduce subscriber re-evaluation during streaming
- wire optimisticSend through useSync shadow Map infrastructure
  matching OpenCode's pattern (no heuristic part detection)
- port OpenCode Identifier.ascending ID format for correct sorting
- pass messageID to promptAsync to prevent duplicate messages
- fix worktree directory not propagating to session actions
  (dynamic dir() via opencodeClient.getDirectory)
- fix setCurrentSession accepting directoryHint for new sessions
- fix revert not hiding messages (session limit was 5, bumped to match loaded count)
- fix revert optimistic message removal from store
- fix load-more flicker (useLayoutEffect scroll compensation)
- add prefetch TTL cache, file content LRU cache
- add session prefetch for adjacent sessions
- add instant archive/delete (optimistic before SDK call)
- migrate legacy window.__zustand_session_store__ to session-ui-store
- add retry + independent error handling for archived sessions
- add AGENTS.md performance rules

* perf: startup optimization — dedup, caching, light git status, diff rendering gates

- defer diff prefetch to git tab open, reduce concurrency 4→2, skip >500 changed lines
- cap project git checks concurrency (2), directory status probe (3)
- dedup provider/agent loading, github auth, worktree list (in-flight + TTL caches)
- delay PR tracking 5s, cache 403 search failures per-repo
- coalesce settings PUT (200ms debounce), cache settings GET (2s TTL)
- cache canonical directory resolution (60s TTL)
- persist missing directory status to localStorage (10min TTL)
- light/heavy git status: polling skips numstat+line counting+rev-list
- large diff rendering gate (>500 lines → "render anyway" button)
- tokenization degradation for >500KB files in Pierre
- parallelize main.tsx pre-render awaits
- batch sidebar file tree expanded paths restoration (3 at a time)
- remove bare useConfigStore() subscription in AgentsPage
- sync worktree sandboxes to OpenCode SQLite DB
- fix RightSidebarTabs ternary → explicit tab matching
- defensive guards on sync state (session_status, permission, question, message)

* fix: add defensive guards on remaining sync state field accesses

guard session_status, permission, message, todo, part, config with ?? {}
in useDirectorySync selectors, session-cache, and bootstrap

* fix: add missing directory dep to useCallback in use-sync.ts

* fix: preserve diffStats when light-mode polling overwrites status

* perf: optimize startup git status polling and diff rendering

Preserves diff stats when lightweight polling updates repository status
Reduces startup overhead with smarter git polling and store updates
Adds detailed optimization and migration docs for next performance steps

* fix: keep chat diff stats stable during git status updates

Prevents lightweight git polling from dropping diff statistics
Keeps MessageList diff indicators consistent while status refreshes
Improves reliability of git-aware chat rendering

* fix: user animation replay, queued message variant, startup provider loading

- consume animation ID after first play to prevent re-animation
  on neighbor assistant message completion
- capture send config (model/agent/variant) at queue time matching
  OpenCode's FollowupDraft pattern instead of re-resolving at send time
- replace one-shot startup recovery effect with polling interval
  that retries every 2s until providers and agents load
- fix optimistic bridge to avoid re-render loop (stable ref wrappers)

* chore: update tauri to 2.10.3 and all plugins to latest

- tauri 2.9.4 → 2.10.3
- tauri-build 2.5.3 → 2.5.6
- tauri-plugin-dialog 2.4.2 → 2.6.0
- tauri-plugin-log 2.7.1 → 2.8.0
- tauri-plugin-shell 2.3.3 → 2.3.5
- tauri-plugin-updater 2 (floating) → 2.10.0 (pinned)
- @tauri-apps/api ^2.9.0 → ^2.10.1
- wry 0.53.5 → 0.54.4 (transitive)

* refactor: decouple web server index orchestration runtimes

* fix: align VS Code runtime behavior with web and reduce draft view CPU load

- Queue VS Code bridge and SSE startup requests until API readiness to avoid false bootstrap failures
- Make agent manager actions directory-aware and remove real worktrees with safer partial-failure handling
- Replace heavy logo animation path with a lightweight pulse to cut draft-session CPU usage

* fix: restore auto-selected file sending in chat input

- Send server-selected files as proper file URLs in the message payload
- Include server-backed attachments in submit flow instead of dropping them
- Restore queued-message attachments through the refactored input store

* fix: restore session model selection consistently on session switch

- Restore agent, model, and variant from the latest loaded user message for each session
- Wait for session messages before applying restored selections to avoid stale or missing state
- Remove legacy session-choice inference paths that caused overlap and instability

* fix: restore permission replies and auto-accept across sessions

- Scope permission and question replies to the target session directory so answers take effect reliably
- Make permission auto-accept immediately handle pending requests and react to new permission prompts
- Keep parent-session handling working for child-session requests through the shared response path

* feat: add reusable fuzzy branch fuzzy-search helper and dialog integration (#798)

* feat: add reusable fuzzy branch search for worktrees

* chore: drop planning docs from feature branch

* feat: make worktree branch refresh manual

* feat: add configurable session retention action

* refactor: centralize global session state in ui store

* fix: cancel debounced permission push after reply

* docs: clarify global and directory session store architecture

* docs: refine agent development rules and session activity guidance

- Clarify agent code of conduct and durable development patterns
- Add explicit shared-store rerender and live-state guidance
- Narrow session activity fallback to avoid stale working state

* chore: updated .gitignore

---------

Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
Bohdan Triapitsyn
2026-03-31 18:47:00 +03:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 8dfe833faf
commit c9e31a0e6c
245 changed files with 31986 additions and 32683 deletions
+217
View File
@@ -0,0 +1,217 @@
# Sync architecture, event handling & store update rules
## Scope
This document covers the current client-side session/data architecture in `packages/ui/src/sync` and the rules for updating stores safely.
There are **two distinct session data scopes** in the UI:
1. **Directory-scoped sync stores**
- Owned by the sync layer child stores created in `sync-context.tsx`
- Source for per-directory live session/message/part/permission/question state
- Backed by SSE / directory-scoped polling
- Read via hooks like `useSessions()`, `useDirectorySync()`, `getSyncSessions()`, `getDirectoryState()`
2. **Global sessions cache**
- Owned by `packages/ui/src/stores/useGlobalSessionsStore.ts`
- Shared source of truth for the Sessions sidebar global lists and Session Retention cleanup
- Holds:
- global active sessions
- global archived sessions
- active sessions indexed by directory
These two scopes are intentionally different.
### Why both exist
The directory-scoped sync stores are **not** a complete global view.
- They are created lazily per directory
- They only contain data for directories initialized in the current app session
- They are optimized for live per-directory domain data
- They do not maintain the complete global active+archived session view needed by the sidebar and retention settings
So:
- Use the **directory sync stores** for per-directory live session/message state
- Use the **global sessions store** for sidebar/retention global session lists
## Ownership map
| Layer / Store | Owns | Scope |
|---|---|---|
| child directory stores in `sync-context.tsx` | `session`, `message`, `part`, `permission`, `question`, etc. | One directory |
| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state |
| `selection-store.ts` | Model/agent/variant selections | App UI state |
| `voice-store.ts` | Voice state | App UI state |
## Session list rules
### Directory-scoped session list
Use the directory-scoped sync store when the UI needs the live session list for the **current directory**.
Examples:
- current chat/session switching
- per-directory session/message bootstrap
- session/message/part SSE updates
### Global session list
Use `useGlobalSessionsStore` when the UI needs a **shared global session view**.
Current consumers:
- `SessionSidebar.tsx`
- `useSessionAutoCleanup.ts`
### Mutation responsibility
`useGlobalSessionsStore` is not maintained by SSE directly. It is kept correct by:
1. shared global fetch/reconciliation via `loadSessions()` / `refreshGlobalSessions()`
2. direct mutation from session actions after successful SDK calls:
- create
- title update
- share
- unshare
- archive
- delete
- retention cleanup batch archive/delete
This keeps sidebar/retention UI responsive without requiring a refetch after every change.
## Session action rules
Session actions live in `session-actions.ts` and are the canonical place for SDK-calling session mutations that affect global session lists.
Rules:
1. If an action mutates session list membership or visible session metadata, update `useGlobalSessionsStore` there.
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
Examples of global-store updates performed in `session-actions.ts`:
- `createSession()` -> `upsertSession(session)`
- `updateSessionTitle()` -> `upsertSession(result.data)`
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
- `archiveSession()` -> `archiveSessions([id], archivedAt)`
- `deleteSession()` -> `removeSessions([id])`
## The golden rule
When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly.
```typescript
// WRONG — clones everything, breaks referential equality for all subscribers
const draft = {
...current,
session: [...current.session],
message: { ...current.message },
part: { ...current.part },
permission: { ...current.permission },
// ...
}
// RIGHT — only clone what this event type touches
const draft = { ...current }
switch (event.type) {
case "message.part.delta":
draft.part = { ...current.part }
break
}
```
## Why this matters
Zustand skips re-renders when a selector returns the same reference (`Object.is`). If you spread `session: [...current.session]` but the event only modifies `part`, the `session` array gets a new reference. Every component using `useSessions()` re-renders for nothing.
During streaming, `message.part.delta` fires ~60 times/sec. Eagerly cloning all fields caused every subscriber in the entire app to re-render 60/sec — a 10x overhead. Targeted cloning reduced MessageList renders from ~1972 to ~296 per session.
## Event → field mapping
Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`:
| Event type | Fields to clone |
|---|---|
| `session.created/updated/deleted` | `session`, `permission`, `todo`, `part` |
| `session.diff` | `session_diff` |
| `session.status` | `session_status` |
| `todo.updated` | `todo` |
| `message.updated` | `message` |
| `message.removed` | `message`, `part` |
| `message.part.updated/removed/delta` | `part` |
| `vcs.branch.updated` | (none — mutates `draft.vcs` directly) |
| `permission.asked/replied` | `permission` |
| `question.asked/replied/rejected` | `question` |
| `lsp.updated` | `lsp` |
## Adding a new event type
1. Add the case to the event reducer (`event-reducer.ts`)
2. Add a corresponding case to the switch in `handleDirectoryEvent` (`sync-context.tsx`) that clones **only** the fields your reducer writes to
3. If your event fires frequently (more than a few times per second), verify that unrelated components don't re-render — check with the stream perf counters
## Selector hygiene
Select leaf values, not containers:
```typescript
// WRONG — returns entire Map/object, new reference on any mutation
useDirectorySync((s) => s.permission)
// RIGHT — returns the value for one key, stable unless that key changes
useDirectorySync((s) => s.permission[sessionID] ?? EMPTY)
```
Same applies to `useStreamingStore` — select `.get(key)` not the Map itself.
## Store splitting pattern
### Why split
A single Zustand store with N properties means every subscriber's selector re-evaluates on every state change — even if the change is unrelated to what that subscriber reads. During streaming, `sessionMemoryState` updates ~60/sec. Before the split, all 68+ `useSessionUIStore` subscribers re-evaluated on each update. After splitting into focused stores, only `useViewportStore` subscribers (2-3 components) re-evaluate.
The optimization multiplies with targeted event cloning: fewer new references per event × fewer subscribers per store = dramatically less work per SSE frame.
### The stores
| Store | Owns | When it changes |
|-------|------|-----------------|
| `session-ui-store.ts` | Session selection, draft lifecycle, abort, worktree, SDK actions | Session switch, draft open/close |
| `voice-store.ts` | Voice connection/activity state | Voice toggle |
| `input-store.ts` | Pending input text, synthetic parts, attached files | User typing, file attach, revert/fork |
| `selection-store.ts` | Per-session model/agent/variant choices | Model/agent picker |
| `viewport-store.ts` | Scroll anchors, session memory state, sync status | Streaming, scroll, session switch |
### Rules for new UI state
1. **Never add to `session-ui-store`** unless it's session selection, draft lifecycle, or abort state
2. **Group by change frequency** — state that changes during streaming (viewport, memory) must not live with state that changes on user action (selections, input)
3. **Group by subscriber set** — if only 2 components read a value, it should be in a store that only those 2 components subscribe to
4. **Prefer a new store over growing an existing one** if the new state has different subscribers or change frequency
5. **Cross-store reads use `.getState()`** — actions in one store that need to read another store call `useOtherStore.getState()` (imperative, no subscription)
### Anti-patterns
```typescript
// WRONG — stuffing unrelated state into one store
const useEverythingStore = create(() => ({
voiceMode: "idle",
scrollAnchor: 0,
selectedModel: null,
pendingInput: "",
// 20 more fields...
}))
// RIGHT — separate stores by concern + change frequency
const useVoiceStore = create(() => ({ voiceMode: "idle" }))
const useViewportStore = create(() => ({ scrollAnchor: 0 }))
const useSelectionStore = create(() => ({ selectedModel: null }))
const useInputStore = create(() => ({ pendingInput: "" }))
```
+46
View File
@@ -0,0 +1,46 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Binary {
export function search<T>(
array: readonly T[],
id: string,
compare: (item: T) => string,
): { found: boolean; index: number } {
let left = 0
let right = array.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
const midId = compare(array[mid])
if (midId === id) {
return { found: true, index: mid }
} else if (midId < id) {
left = mid + 1
} else {
right = mid - 1
}
}
return { found: false, index: left }
}
export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
const id = compare(item)
let left = 0
let right = array.length
while (left < right) {
const mid = Math.floor((left + right) / 2)
const midId = compare(array[mid])
if (midId < id) {
left = mid + 1
} else {
right = mid
}
}
array.splice(left, 0, item)
return array
}
}
+161
View File
@@ -0,0 +1,161 @@
import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client"
import { retry } from "./retry"
import type { GlobalState, State } from "./types"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
return input.reduce<Record<string, T[]>>((acc, item) => {
if (!item?.id || !item.sessionID) return acc
const list = acc[item.sessionID]
if (list) list.push(item)
else acc[item.sessionID] = [item]
return acc
}, {})
}
function projectID(directory: string, projects: Project[]) {
return projects.find(
(project) => project.worktree === directory || project.sandboxes?.includes(directory),
)?.id
}
// ---------------------------------------------------------------------------
// Bootstrap global state
// ---------------------------------------------------------------------------
export async function bootstrapGlobal(
sdk: OpencodeClient,
set: (patch: Partial<GlobalState>) => void,
) {
const results = await Promise.allSettled([
retry(() => sdk.path.get().then((x) => set({ path: x.data! }))),
retry(() => sdk.global.config.get().then((x) => set({ config: x.data! }))),
retry(() =>
sdk.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p): p is Project => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.sort((a, b) => cmp(a.id, b.id))
set({ projects })
}),
),
retry(() => sdk.provider.list().then((x) => set({ providers: x.data! }))),
])
const errors = results
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.map((r) => r.reason)
if (errors.length) {
console.error("[bootstrap] global bootstrap failed", errors[0])
}
set({ ready: true })
}
// ---------------------------------------------------------------------------
// Bootstrap per-directory state
// ---------------------------------------------------------------------------
export async function bootstrapDirectory(input: {
directory: string
sdk: OpencodeClient
getState: () => State
set: (patch: Partial<State>) => void
global: {
config: Record<string, unknown>
projects: Project[]
providers: { all: unknown[]; connected: unknown[]; default: Record<string, unknown> }
}
loadSessions: (directory: string) => Promise<void> | void
}) {
const { directory, sdk, getState, set, global: g } = input
const state = getState()
const loading = state.status !== "complete"
// Seed from global state while we fetch directory-specific data
const seededProject = projectID(directory, g.projects)
if (seededProject) set({ project: seededProject })
if (state.provider.all.length === 0 && g.providers.all.length > 0) {
set({ provider: g.providers as State["provider"] })
}
if (Object.keys(state.config ?? {}).length === 0 && Object.keys(g.config ?? {}).length > 0) {
set({ config: g.config as State["config"] })
}
if (loading) set({ status: "partial" })
const results = await Promise.allSettled([
seededProject
? Promise.resolve()
: retry(() => sdk.project.current().then((x) => set({ project: x.data!.id }))),
retry(() => sdk.provider.list().then((x) => set({ provider: x.data! }))),
retry(() => sdk.app.agents().then((x) => set({ agent: x.data ?? [] }))),
retry(() => sdk.config.get().then((x) => set({ config: x.data! }))),
retry(() =>
sdk.path.get().then((x) => {
set({ path: x.data! })
const next = projectID(x.data?.directory ?? directory, g.projects)
if (next) set({ project: next })
}),
),
retry(() => sdk.command.list().then((x) => set({ command: x.data ?? [] }))),
retry(() => sdk.session.status().then((x) => set({ session_status: x.data! }))),
input.loadSessions(directory),
retry(() => sdk.mcp.status().then((x) => set({ mcp: x.data! }))),
retry(() => sdk.lsp.status().then((x) => set({ lsp: x.data! }))),
retry(() =>
sdk.vcs.get().then((x) => {
const current = getState()
set({ vcs: x.data ?? current.vcs })
}),
),
retry(() =>
sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
const permission: Record<string, PermissionRequest[]> = {}
// Clear sessions no longer having permissions
const current = getState()
for (const sessionID of Object.keys(current.permission ?? {})) {
if (!grouped[sessionID]) permission[sessionID] = []
}
// Set grouped permissions sorted by id
for (const [sessionID, perms] of Object.entries(grouped)) {
permission[sessionID] = perms
.filter((p) => !!p?.id)
.sort((a, b) => cmp(a.id, b.id))
}
set({ permission })
}),
),
retry(() =>
sdk.question.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID),
)
const question: Record<string, QuestionRequest[]> = {}
const current = getState()
for (const sessionID of Object.keys(current.question ?? {})) {
if (!grouped[sessionID]) question[sessionID] = []
}
for (const [sessionID, questions] of Object.entries(grouped)) {
question[sessionID] = questions
.filter((q) => !!q?.id)
.sort((a, b) => cmp(a.id, b.id))
}
set({ question })
}),
),
])
const errors = results
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.map((r) => r.reason)
if (errors.length) {
console.error(`[bootstrap] directory bootstrap failed for ${directory}`, errors[0])
return
}
if (loading) set({ status: "complete" })
}
+172
View File
@@ -0,0 +1,172 @@
import { create, type StoreApi } from "zustand"
import type { DirState, State } from "./types"
import { INITIAL_STATE, MAX_DIR_STORES, DIR_IDLE_TTL_MS } from "./types"
import { pickDirectoriesToEvict, canDisposeDirectory } from "./eviction"
import { readDirCache, persistVcs, persistProjectMeta, persistIcon } from "./persist-cache"
export type DirectoryStore = State & {
/** Apply a partial state update */
patch: (partial: Partial<State>) => void
/** Replace state wholesale (used during bootstrap) */
replace: (next: State) => void
}
function createDirectoryStore(directory: string): StoreApi<DirectoryStore> {
// Restore cached metadata from localStorage
const cached = readDirCache(directory)
const store = create<DirectoryStore>()((set) => ({
...INITIAL_STATE,
vcs: cached.vcs ?? INITIAL_STATE.vcs,
projectMeta: cached.projectMeta ?? INITIAL_STATE.projectMeta,
icon: cached.icon ?? INITIAL_STATE.icon,
patch: (partial) => set(partial),
replace: (next) => set(next),
}))
// Subscribe to persist metadata changes back to localStorage
store.subscribe((state, prev) => {
if (state.vcs !== prev.vcs) persistVcs(directory, state.vcs)
if (state.projectMeta !== prev.projectMeta) persistProjectMeta(directory, state.projectMeta)
if (state.icon !== prev.icon) persistIcon(directory, state.icon)
})
return store
}
export class ChildStoreManager {
readonly children = new Map<string, StoreApi<DirectoryStore>>()
private readonly lifecycle = new Map<string, DirState>()
private readonly pins = new Map<string, number>()
private readonly disposers = new Map<string, () => void>()
private onBootstrap?: (directory: string) => void
private onDispose?: (directory: string) => void
private isBooting?: (directory: string) => boolean
private isLoadingSessions?: (directory: string) => boolean
configure(callbacks: {
onBootstrap?: (directory: string) => void
onDispose?: (directory: string) => void
isBooting?: (directory: string) => boolean
isLoadingSessions?: (directory: string) => boolean
}) {
this.onBootstrap = callbacks.onBootstrap
this.onDispose = callbacks.onDispose
this.isBooting = callbacks.isBooting
this.isLoadingSessions = callbacks.isLoadingSessions
}
mark(directory: string) {
if (!directory) return
this.lifecycle.set(directory, { lastAccessAt: Date.now() })
this.runEviction(directory)
}
pin(directory: string) {
if (!directory) return
this.pins.set(directory, (this.pins.get(directory) ?? 0) + 1)
this.mark(directory)
}
unpin(directory: string) {
if (!directory) return
const next = (this.pins.get(directory) ?? 0) - 1
if (next > 0) {
this.pins.set(directory, next)
return
}
this.pins.delete(directory)
this.runEviction()
}
pinned(directory: string) {
return (this.pins.get(directory) ?? 0) > 0
}
ensureChild(directory: string, options?: { bootstrap?: boolean }): StoreApi<DirectoryStore> {
if (!directory) throw new Error("No directory provided to ensureChild")
let store = this.children.get(directory)
if (!store) {
store = createDirectoryStore(directory)
this.children.set(directory, store)
}
this.mark(directory)
const shouldBootstrap = options?.bootstrap ?? true
if (shouldBootstrap && store.getState().status === "loading") {
this.onBootstrap?.(directory)
}
return store
}
getChild(directory: string): StoreApi<DirectoryStore> | undefined {
return this.children.get(directory)
}
disposeDirectory(directory: string): boolean {
if (
!canDisposeDirectory({
directory,
hasStore: this.children.has(directory),
pinned: this.pinned(directory),
booting: this.isBooting?.(directory) ?? false,
loadingSessions: this.isLoadingSessions?.(directory) ?? false,
})
) {
return false
}
this.lifecycle.delete(directory)
this.children.delete(directory)
const dispose = this.disposers.get(directory)
if (dispose) {
dispose()
this.disposers.delete(directory)
}
this.onDispose?.(directory)
return true
}
runEviction(skip?: string) {
const stores = [...this.children.keys()]
if (stores.length === 0) return
const list = pickDirectoriesToEvict({
stores,
state: this.lifecycle,
pins: new Set(stores.filter((d) => this.pinned(d))),
max: MAX_DIR_STORES,
ttl: DIR_IDLE_TTL_MS,
now: Date.now(),
}).filter((d) => d !== skip)
for (const directory of list) {
this.disposeDirectory(directory)
}
}
/** Apply a state mutation to a directory's store */
update(directory: string, fn: (state: State) => Partial<State>) {
const store = this.children.get(directory)
if (!store) return
const current = store.getState()
const patch = fn(current)
store.setState(patch)
}
/** Get current state of a directory store (snapshot) */
getState(directory: string): State | undefined {
return this.children.get(directory)?.getState()
}
disposeAll() {
for (const directory of [...this.children.keys()]) {
this.children.delete(directory)
}
this.lifecycle.clear()
this.pins.clear()
this.disposers.clear()
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* File content LRU cache — dual constraint eviction.
* Port of OpenCode's content-cache.ts.
*
* Evicts when either entry count exceeds MAX_FILE_CONTENT_ENTRIES
* or total byte estimate exceeds MAX_FILE_CONTENT_BYTES.
* Uses Map insertion order as LRU (oldest = first key).
*/
const MAX_FILE_CONTENT_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 // 20 MB
// LRU map: path → approximate bytes. Map insertion order = access order.
const lru = new Map<string, number>()
let total = 0
/** Estimate byte size of a string (UTF-16 → ~2 bytes per char). */
export function approxStringBytes(content: string): number {
return content.length * 2
}
function setBytes(path: string, nextBytes: number) {
const prev = lru.get(path)
if (prev !== undefined) total -= prev
lru.delete(path)
lru.set(path, nextBytes)
total += nextBytes
}
function touch(path: string, bytes?: number) {
const prev = lru.get(path)
if (prev === undefined && bytes === undefined) return
setBytes(path, bytes ?? prev ?? 0)
}
function remove(path: string) {
const prev = lru.get(path)
if (prev === undefined) return
lru.delete(path)
total -= prev
}
/**
* Evict entries until both constraints are satisfied.
* @param keep - paths to preserve (moved to end of LRU if encountered)
* @param evict - callback to actually remove content from the store
*/
export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
const safeSet = keep ?? new Set<string>()
while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) {
const path = lru.keys().next().value
if (!path) return
if (safeSet.has(path)) {
touch(path)
if (lru.size <= safeSet.size) return
continue
}
remove(path)
evict(path)
}
}
export function resetContentLru() {
lru.clear()
total = 0
}
export function setContentBytes(path: string, bytes: number) {
setBytes(path, bytes)
}
export function removeContentBytes(path: string) {
remove(path)
}
export function touchContent(path: string, bytes?: number) {
touch(path, bytes)
}
export function getContentBytesTotal(): number {
return total
}
export function getContentEntryCount(): number {
return lru.size
}
export function hasContent(path: string): boolean {
return lru.has(path)
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Event Pipeline — SSE connection, event coalescing, and batched flush.
*
* Plain closure API:
* const { cleanup } = createEventPipeline({ sdk, onEvent })
*
* No class, no start/stop lifecycle. One pipeline per mount.
* Abort controller created once at init, cleaned up via returned cleanup fn.
*/
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2/client"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type QueuedEvent = {
directory: string
payload: Event
}
export type FlushHandler = (events: QueuedEvent[]) => void
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
const HEARTBEAT_TIMEOUT_MS = 15_000
// ---------------------------------------------------------------------------
// Pipeline factory
// ---------------------------------------------------------------------------
export function createEventPipeline(input: {
sdk: OpencodeClient
onEvent: (directory: string, payload: Event) => void
}) {
const { sdk, onEvent } = input
const abort = new AbortController()
// Queue state
let queue: QueuedEvent[] = []
let buffer: QueuedEvent[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) =>
`${directory}:${messageID}:${partID}`
// Coalesce key — same-type events for the same entity replace earlier ones
const key = (directory: string, payload: Event): string | undefined => {
if (payload.type === "session.status") {
const props = payload.properties as { sessionID: string }
return `session.status:${directory}:${props.sessionID}`
}
if (payload.type === "lsp.updated") {
return `lsp.updated:${directory}`
}
if (payload.type === "message.part.updated") {
const part = (payload.properties as { part: { messageID: string; id: string } }).part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
return undefined
}
// Flush — swap queue, dispatch events, skip stale deltas
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
// React 18 batches synchronous setState calls automatically,
// equivalent to SolidJS batch()
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties as { messageID: string; partID: string }
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
onEvent(event.directory, event.payload)
}
buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
// Helpers
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const isAbortError = (error: unknown): boolean =>
error instanceof DOMException && error.name === "AbortError" ||
(typeof error === "object" && error !== null && (error as { name?: string }).name === "AbortError")
let streamErrorLogged = false
let attempt: AbortController | undefined
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
// SSE loop — iterate SDK global event stream, enqueue with coalescing
void (async () => {
while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await sdk.global.event({
signal: attempt.signal,
onSseError: (error: unknown) => {
if (isAbortError(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[event-pipeline] stream error", error)
},
})
let yielded = Date.now()
resetHeartbeat()
// Enqueue event with coalescing + stale delta tracking
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = (event as { directory?: string }).directory ?? "global"
const payload = (event as { payload?: Event }).payload ?? (event as unknown as Event)
if (!payload || typeof payload !== "object" || typeof (payload as { type?: unknown }).type !== "string") {
continue
}
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = (payload.properties as { part: { messageID: string; id: string } }).part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!isAbortError(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[event-pipeline] stream failed", error)
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(flush)
// Visibility handler — flush immediately when tab becomes visible
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
}
// Cleanup — abort SSE, flush remaining events, remove listeners
const cleanup = () => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
abort.abort()
flush()
}
return { cleanup }
}
+345
View File
@@ -0,0 +1,345 @@
import type {
Event,
FileDiff,
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import type { GlobalState, State } from "./types"
import { dropSessionCaches } from "./session-cache"
import { stripSessionDiffSnapshots } from "./sanitize"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
// ---------------------------------------------------------------------------
// Global events
// ---------------------------------------------------------------------------
export type GlobalEventResult = {
type: "refresh"
} | {
type: "project"
project: Project
} | null
export function reduceGlobalEvent(event: Event): GlobalEventResult {
if (event.type === "global.disposed" || event.type === "server.connected") {
return { type: "refresh" }
}
if (event.type === "project.updated") {
return { type: "project", project: event.properties as Project }
}
return null
}
export function applyGlobalProject(state: GlobalState, project: Project): GlobalState {
const projects = [...state.projects]
const result = Binary.search(projects, project.id, (s) => s.id)
if (result.found) {
projects[result.index] = { ...projects[result.index], ...project }
} else {
projects.splice(result.index, 0, project)
}
return { ...state, projects }
}
// ---------------------------------------------------------------------------
// Directory events — mutates draft in place for batching efficiency.
// Caller MUST pass a mutable copy of State (e.g. structuredClone or spread).
// ---------------------------------------------------------------------------
export function applyDirectoryEvent(
draft: State,
event: Event,
callbacks?: {
onRefresh?: (directory: string) => void
onLoadLsp?: () => void
onSetSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
},
): boolean {
switch (event.type) {
case "server.instance.disposed": {
callbacks?.onRefresh?.("")
return false
}
case "session.created": {
const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info)
const sessions = draft.session
const result = Binary.search(sessions, info.id, (s) => s.id)
if (result.found) {
sessions[result.index] = info
} else {
sessions.splice(result.index, 0, info)
trimSessions(draft)
if (!info.parentID) draft.sessionTotal += 1
}
return true
}
case "session.updated": {
const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info)
const sessions = draft.session
const result = Binary.search(sessions, info.id, (s) => s.id)
if (info.time.archived) {
if (result.found) sessions.splice(result.index, 1)
cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo)
if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1)
return true
}
if (result.found) {
sessions[result.index] = info
} else {
sessions.splice(result.index, 0, info)
trimSessions(draft)
}
return true
}
case "session.deleted": {
const info = (event.properties as { info: Session }).info
const sessions = draft.session
const result = Binary.search(sessions, info.id, (s) => s.id)
if (result.found) sessions.splice(result.index, 1)
cleanupSessionCaches(draft, info.id, callbacks?.onSetSessionTodo)
if (!info.parentID) draft.sessionTotal = Math.max(0, draft.sessionTotal - 1)
return true
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiff[] }
draft.session_diff[props.sessionID] = props.diff
return true
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
draft.todo[props.sessionID] = props.todos
callbacks?.onSetSessionTodo?.(props.sessionID, props.todos)
return true
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionStatus }
draft.session_status[props.sessionID] = props.status
return true
}
case "message.updated": {
const info = (event.properties as { info: Message }).info
const messages = draft.message[info.sessionID]
if (!messages) {
draft.message[info.sessionID] = [info]
return true
}
const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) {
// Skip message replacement if unchanged — preserves reference, avoids re-render
const existing = messages[result.index]
const unchanged = existing.role === info.role
&& (existing as { finish?: unknown }).finish === (info as { finish?: unknown }).finish
&& (existing.time as { completed?: number })?.completed === (info.time as { completed?: number })?.completed
if (unchanged) {
return false
}
const next = [...messages]
next[result.index] = info
draft.message[info.sessionID] = next
} else {
const next = [...messages]
next.splice(result.index, 0, info)
draft.message[info.sessionID] = next
}
return true
}
case "message.removed": {
const props = event.properties as { sessionID: string; messageID: string }
const messages = draft.message[props.sessionID]
if (messages) {
const next = [...messages]
const result = Binary.search(next, props.messageID, (m) => m.id)
if (result.found) {
next.splice(result.index, 1)
draft.message[props.sessionID] = next
}
}
delete draft.part[props.messageID]
return true
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) return false
const messageID = (part as { messageID: string }).messageID
const parts = draft.part[messageID]
if (!parts) {
draft.part[messageID] = [part]
return true
}
const next = [...parts]
const result = Binary.search(next, part.id, (p) => p.id)
if (result.found) {
next[result.index] = part
} else {
// Replace optimistic part (no sessionID) with server part of same type.
// Gate: only scan if the first part lacks sessionID (optimistic parts are
// always inserted first). Assistant messages never have optimistic parts,
// so this check is effectively free during streaming.
const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID
const optimisticIdx = hasOptimistic && (part.type === "text" || part.type === "file")
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
: -1
if (optimisticIdx >= 0) {
next.splice(optimisticIdx, 1)
}
const insertResult = Binary.search(next, part.id, (p) => p.id)
next.splice(insertResult.index, 0, part)
}
draft.part[messageID] = next
return true
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
const parts = draft.part[props.messageID]
if (!parts) return false
const result = Binary.search(parts, props.partID, (p) => p.id)
if (result.found) {
const next = [...parts]
next.splice(result.index, 1)
if (next.length === 0) {
delete draft.part[props.messageID]
} else {
draft.part[props.messageID] = next
}
return true
}
return false
}
case "message.part.delta": {
const props = event.properties as {
messageID: string
partID: string
field: string
delta: string
}
const parts = draft.part[props.messageID]
if (!parts) return false
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) return false
const existing = parts[result.index] as Record<string, unknown>
const existingValue = existing[props.field] as string | undefined
// Create new Part object + new array so React detects the change
const next = [...parts]
next[result.index] = { ...existing, [props.field]: (existingValue ?? "") + props.delta } as Part
draft.part[props.messageID] = next
return true
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (draft.vcs?.branch === props.branch) return false
draft.vcs = { branch: props.branch }
return true
}
case "permission.asked": {
const permission = event.properties as PermissionRequest
const permissions = draft.permission[permission.sessionID] ?? []
draft.permission[permission.sessionID] = permissions
const result = Binary.search(permissions, permission.id, (p) => p.id)
if (result.found) {
permissions[result.index] = permission
} else {
permissions.splice(result.index, 0, permission)
}
return true
}
case "permission.replied": {
const props = event.properties as { sessionID: string; requestID: string }
const permissions = draft.permission[props.sessionID]
if (!permissions) return false
const result = Binary.search(permissions, props.requestID, (p) => p.id)
if (result.found) {
permissions.splice(result.index, 1)
return true
}
return false
}
case "question.asked": {
const question = event.properties as QuestionRequest
const questions = draft.question[question.sessionID] ?? []
draft.question[question.sessionID] = questions
const result = Binary.search(questions, question.id, (q) => q.id)
if (result.found) {
questions[result.index] = question
} else {
questions.splice(result.index, 0, question)
}
return true
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
const questions = draft.question[props.sessionID]
if (!questions) return false
const result = Binary.search(questions, props.requestID, (q) => q.id)
if (result.found) {
questions.splice(result.index, 1)
return true
}
return false
}
case "lsp.updated": {
callbacks?.onLoadLsp?.()
return false
}
default:
return false
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function trimSessions(draft: State) {
if (draft.session.length <= draft.limit) return
// Keep sessions that have pending permissions (they need to stay visible)
const hasPermission = new Set(
Object.entries(draft.permission ?? {})
.filter(([, perms]) => perms && perms.length > 0)
.map(([sessionID]) => sessionID),
)
while (draft.session.length > draft.limit) {
// Remove from the beginning (oldest by sorted ID)
const candidate = draft.session[0]
if (hasPermission.has(candidate.id)) break
draft.session.shift()
}
}
function cleanupSessionCaches(
draft: State,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
setSessionTodo?.(sessionID, undefined)
dropSessionCaches(draft, [sessionID])
}
+28
View File
@@ -0,0 +1,28 @@
import type { DisposeCheck, EvictPlan } from "./types"
export function pickDirectoriesToEvict(input: EvictPlan) {
const overflow = Math.max(0, input.stores.length - input.max)
let pendingOverflow = overflow
const sorted = input.stores
.filter((dir) => !input.pins.has(dir))
.slice()
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
const output: string[] = []
for (const dir of sorted) {
const last = input.state.get(dir)?.lastAccessAt ?? 0
const idle = input.now - last >= input.ttl
if (!idle && pendingOverflow <= 0) continue
output.push(dir)
if (pendingOverflow > 0) pendingOverflow -= 1
}
return output
}
export function canDisposeDirectory(input: DisposeCheck) {
if (!input.directory) return false
if (!input.hasStore) return false
if (input.pinned) return false
if (input.booting) return false
if (input.loadingSessions) return false
return true
}
+27
View File
@@ -0,0 +1,27 @@
import { create } from "zustand"
import type { GlobalState } from "./types"
import { INITIAL_GLOBAL_STATE } from "./types"
export type GlobalSyncStore = GlobalState & {
actions: {
set: (patch: Partial<GlobalState>) => void
reset: () => void
}
}
export const useGlobalSyncStore = create<GlobalSyncStore>()((set) => ({
...INITIAL_GLOBAL_STATE,
actions: {
set: (patch) => set(patch),
reset: () => set(INITIAL_GLOBAL_STATE),
},
}))
// Fine-grained selectors — use these in components for minimal re-renders
export const selectReady = (s: GlobalSyncStore) => s.ready
export const selectProjects = (s: GlobalSyncStore) => s.projects
export const selectProviders = (s: GlobalSyncStore) => s.providers
export const selectConfig = (s: GlobalSyncStore) => s.config
export const selectPath = (s: GlobalSyncStore) => s.path
export const selectReload = (s: GlobalSyncStore) => s.reload
export const selectSessionTodo = (s: GlobalSyncStore) => s.sessionTodo
+152
View File
@@ -0,0 +1,152 @@
// Core utilities
export { Binary } from "./binary"
export { retry, type RetryOptions } from "./retry"
// Types
export type { State, GlobalState, ProjectMeta, DirState, EvictPlan, DisposeCheck, ChildOptions } from "./types"
export {
INITIAL_STATE,
INITIAL_GLOBAL_STATE,
MAX_DIR_STORES,
DIR_IDLE_TTL_MS,
SESSION_CACHE_LIMIT,
SESSION_RECENT_LIMIT,
SESSION_RECENT_WINDOW,
} from "./types"
// Eviction
export { pickDirectoriesToEvict, canDisposeDirectory } from "./eviction"
// Session cache
export { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
// Optimistic
export {
applyOptimisticAdd,
applyOptimisticRemove,
mergeOptimisticPage,
mergeMessages,
type OptimisticItem,
type OptimisticStore,
type OptimisticAddInput,
type OptimisticRemoveInput,
type MessagePage,
} from "./optimistic"
// Event reducer
export {
reduceGlobalEvent,
applyGlobalProject,
applyDirectoryEvent,
type GlobalEventResult,
} from "./event-reducer"
// Event pipeline
export { createEventPipeline, type QueuedEvent, type FlushHandler } from "./event-pipeline"
// Stores
export { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store"
export { ChildStoreManager, type DirectoryStore } from "./child-store"
// Bootstrap
export { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
// React integration
export {
SyncProvider,
useGlobalSync,
useGlobalSyncSelector,
useDirectoryStore,
useDirectorySync,
useSessionMessages,
useSessionParts,
useSessionStatus,
useSessionPermissions,
useSessionQuestions,
useSessions,
useSyncSDK,
useSyncDirectory,
useChildStoreManager,
useSessionMessageRecords,
} from "./sync-context"
// Sync operations
export { useSync } from "./use-sync"
// Prompt submission
export { usePromptSubmit, type SubmitInput } from "./submit"
// Streaming lifecycle
export {
useStreamingStore,
updateStreamingState,
selectStreamingMessageId,
selectMessageStreamState,
selectIsStreaming,
type StreamPhase,
type MessageStreamState,
type StreamingStore,
} from "./streaming"
// Session UI state
export {
useSessionUIStore,
type SessionUIState,
type AttachedFile,
type NewSessionDraftState,
} from "./session-ui-store"
// Input store (pending input, synthetic parts, attached files)
export { useInputStore, type SyntheticContextPart } from "./input-store"
// Viewport store (per-session scroll anchors, memory state)
export {
useViewportStore,
type SessionMemoryState,
type ViewportState,
} from "./viewport-store"
// Sync refs (imperative access from non-React code)
export {
setSyncRefs,
getSyncSDK,
getSyncChildStores,
getSyncDirectory,
getDirectoryState,
getSyncSessions,
getSyncMessages,
getSyncParts,
getSyncSessionStatus,
getSyncPermissions,
getSyncQuestions,
} from "./sync-refs"
// Persisted metadata caches
export {
readDirCache,
persistVcs,
persistProjectMeta,
persistIcon,
clearDirCache,
type PersistedDirCache,
} from "./persist-cache"
// Session actions
export {
setActionRefs,
createSession,
deleteSession,
archiveSession,
updateSessionTitle,
shareSession,
unshareSession,
optimisticSend,
abortCurrentOperation,
respondToPermission,
dismissPermission,
respondToQuestion,
rejectQuestion,
revertToMessage,
forkFromMessage,
} from "./session-actions"
+79
View File
@@ -0,0 +1,79 @@
/**
* Input Store — pending input text, synthetic parts, and attached files.
* Extracted from session-ui-store for subscription isolation.
*/
import { create } from "zustand"
import type { AttachedFile } from "@/stores/types/sessionTypes"
export type SyntheticContextPart = {
text: string
attachments?: AttachedFile[]
synthetic?: boolean
}
export type InputState = {
pendingInputText: string | null
pendingInputMode: "replace" | "append" | "append-inline"
pendingSyntheticParts: SyntheticContextPart[] | null
attachedFiles: AttachedFile[]
setPendingInputText: (text: string | null, mode?: "replace" | "append" | "append-inline") => void
consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void
consumePendingSyntheticParts: () => SyntheticContextPart[] | null
addAttachedFile: (file: File) => Promise<void>
removeAttachedFile: (id: string) => void
clearAttachedFiles: () => void
}
export const useInputStore = create<InputState>()((set, get) => ({
pendingInputText: null,
pendingInputMode: "replace",
pendingSyntheticParts: null,
attachedFiles: [],
setPendingInputText: (text, mode = "replace") =>
set({ pendingInputText: text, pendingInputMode: mode }),
consumePendingInputText: () => {
const { pendingInputText, pendingInputMode } = get()
if (pendingInputText === null) return null
set({ pendingInputText: null, pendingInputMode: "replace" })
return { text: pendingInputText, mode: pendingInputMode }
},
setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }),
consumePendingSyntheticParts: () => {
const { pendingSyntheticParts } = get()
if (pendingSyntheticParts !== null) {
set({ pendingSyntheticParts: null })
}
return pendingSyntheticParts
},
addAttachedFile: async (file: File) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(file)
})
const attached: AttachedFile = {
id,
file,
dataUrl,
mimeType: file.type,
filename: file.name,
size: file.size,
source: "local",
}
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
},
removeAttachedFile: (id) =>
set((s) => ({ attachedFiles: s.attachedFiles.filter((f) => f.id !== id) })),
clearAttachedFiles: () => set({ attachedFiles: [] }),
}))
+170
View File
@@ -0,0 +1,170 @@
// ---------------------------------------------------------------------------
// Notification store — session turn-complete and error tracking
//
// Tracks session turn-complete and error notifications with viewed/unviewed
// state. Replaces the old sessionAttentionStates polling system.
// ---------------------------------------------------------------------------
import { create } from "zustand"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type NotificationBase = {
directory?: string
session?: string
time: number
viewed: boolean
}
type TurnCompleteNotification = NotificationBase & {
type: "turn-complete"
}
type ErrorNotification = NotificationBase & {
type: "error"
error?: { message?: string; code?: string }
}
export type Notification = TurnCompleteNotification | ErrorNotification
type NotificationIndex = {
session: {
unseenCount: Record<string, number>
unseenHasError: Record<string, boolean>
}
project: {
unseenCount: Record<string, number>
unseenHasError: Record<string, boolean>
}
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 days
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pruneNotifications(list: Notification[]): Notification[] {
const cutoff = Date.now() - NOTIFICATION_TTL_MS
const pruned = list.filter((n) => n.time >= cutoff)
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
}
function buildIndex(list: Notification[]): NotificationIndex {
const index: NotificationIndex = {
session: { unseenCount: {}, unseenHasError: {} },
project: { unseenCount: {}, unseenHasError: {} },
}
for (const n of list) {
if (n.viewed) continue
if (n.session) {
index.session.unseenCount[n.session] = (index.session.unseenCount[n.session] ?? 0) + 1
if (n.type === "error") index.session.unseenHasError[n.session] = true
}
if (n.directory) {
index.project.unseenCount[n.directory] = (index.project.unseenCount[n.directory] ?? 0) + 1
if (n.type === "error") index.project.unseenHasError[n.directory] = true
}
}
return index
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
interface NotificationStore {
list: Notification[]
index: NotificationIndex
// Mutations
append: (notification: Notification) => void
markSessionViewed: (sessionId: string) => void
markProjectViewed: (directory: string) => void
// Selectors
sessionUnseenCount: (sessionId: string) => number
sessionHasError: (sessionId: string) => boolean
projectUnseenCount: (directory: string) => number
projectHasError: (directory: string) => boolean
}
export const useNotificationStore = create<NotificationStore>((set, get) => ({
list: [],
index: {
session: { unseenCount: {}, unseenHasError: {} },
project: { unseenCount: {}, unseenHasError: {} },
},
append: (notification) => {
const current = get().list
const next = pruneNotifications([...current, notification])
set({ list: next, index: buildIndex(next) })
},
markSessionViewed: (sessionId) => {
const current = get()
const count = current.index.session.unseenCount[sessionId] ?? 0
if (count === 0) return
const next = current.list.map((n) =>
n.session === sessionId && !n.viewed ? { ...n, viewed: true } : n,
)
set({ list: next, index: buildIndex(next) })
},
markProjectViewed: (directory) => {
const current = get()
const count = current.index.project.unseenCount[directory] ?? 0
if (count === 0) return
const next = current.list.map((n) =>
n.directory === directory && !n.viewed ? { ...n, viewed: true } : n,
)
set({ list: next, index: buildIndex(next) })
},
sessionUnseenCount: (sessionId) => get().index.session.unseenCount[sessionId] ?? 0,
sessionHasError: (sessionId) => get().index.session.unseenHasError[sessionId] ?? false,
projectUnseenCount: (directory) => get().index.project.unseenCount[directory] ?? 0,
projectHasError: (directory) => get().index.project.unseenHasError[directory] ?? false,
}))
// ---------------------------------------------------------------------------
// Imperative API for non-React code (event handler in sync-context)
// ---------------------------------------------------------------------------
export function appendNotification(notification: Notification) {
useNotificationStore.getState().append(notification)
}
export function markSessionViewed(sessionId: string) {
useNotificationStore.getState().markSessionViewed(sessionId)
}
// ---------------------------------------------------------------------------
// React hooks for fine-grained subscriptions
// ---------------------------------------------------------------------------
export function useSessionUnseenCount(sessionId: string): number {
return useNotificationStore((s) => s.index.session.unseenCount[sessionId] ?? 0)
}
export function useSessionHasError(sessionId: string): boolean {
return useNotificationStore((s) => s.index.session.unseenHasError[sessionId] ?? false)
}
export function useProjectUnseenCount(directory: string): number {
return useNotificationStore((s) => s.index.project.unseenCount[directory] ?? 0)
}
+127
View File
@@ -0,0 +1,127 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
export type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
export type OptimisticItem = {
message: Message
parts: Part[]
}
export type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
export type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
export type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()]
.sort((a, b) => cmp(a[0], b[0]))
.map(([id, part]) => ({ id, part })),
confirmed,
}
}
/** Apply optimistic add to a mutable draft (for immer/produce) */
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
if (!result.found) {
messages.splice(result.index, 0, input.message)
}
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
/** Apply optimistic remove to a mutable draft (for immer/produce) */
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
/** Merge two sorted message arrays by id, deduplicating.
* Preserves references from `a` for items that already exist — avoids
* unnecessary React re-renders when prepending older history. */
export function mergeMessages<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const existing = new Map(a.map((item) => [item.id, item] as const))
let changed = false
for (const item of b) {
if (!existing.has(item.id)) {
existing.set(item.id, item)
changed = true
}
}
if (!changed) return a as T[]
return [...existing.values()].sort((x, y) => cmp(x.id, y.id))
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Persisted child-store metadata caches.
*
* VCS info, project metadata, and icons are cached to localStorage
* per directory so they survive page reloads.
* Only metadata is persisted — session/message/part data is always fresh
* from the server via SSE bootstrap.
*/
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { ProjectMeta } from "./types"
// ---------------------------------------------------------------------------
// Storage key generation
// ---------------------------------------------------------------------------
function hashCode(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const chr = str.charCodeAt(i)
hash = ((hash << 5) - hash) + chr
hash |= 0
}
return Math.abs(hash).toString(36)
}
function storagePrefix(directory: string): string {
const head = directory.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "_")
return `oc.dir.${head}.${hashCode(directory)}`
}
// ---------------------------------------------------------------------------
// Typed cache helpers
// ---------------------------------------------------------------------------
type CacheKey = "vcs" | "projectMeta" | "icon"
function cacheKey(directory: string, key: CacheKey): string {
return `${storagePrefix(directory)}.${key}`
}
function readCache<T>(directory: string, key: CacheKey): T | undefined {
try {
const raw = localStorage.getItem(cacheKey(directory, key))
if (!raw) return undefined
return JSON.parse(raw) as T
} catch {
return undefined
}
}
function writeCache<T>(directory: string, key: CacheKey, value: T | undefined): void {
try {
const k = cacheKey(directory, key)
if (value === undefined) {
localStorage.removeItem(k)
} else {
localStorage.setItem(k, JSON.stringify(value))
}
} catch {
// localStorage quota exceeded — ignore
}
}
function clearCache(directory: string): void {
try {
const prefix = storagePrefix(directory)
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k?.startsWith(prefix)) keys.push(k)
}
for (const k of keys) localStorage.removeItem(k)
} catch {
// ignore
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export type PersistedDirCache = {
vcs: VcsInfo | undefined
projectMeta: ProjectMeta | undefined
icon: string | undefined
}
/** Read all cached metadata for a directory */
export function readDirCache(directory: string): PersistedDirCache {
return {
vcs: readCache<VcsInfo>(directory, "vcs"),
projectMeta: readCache<ProjectMeta>(directory, "projectMeta"),
icon: readCache<string>(directory, "icon"),
}
}
/** Write vcs info to cache */
export function persistVcs(directory: string, vcs: VcsInfo | undefined): void {
writeCache(directory, "vcs", vcs)
}
/** Write project metadata to cache */
export function persistProjectMeta(directory: string, meta: ProjectMeta | undefined): void {
writeCache(directory, "projectMeta", meta)
}
/** Write icon to cache */
export function persistIcon(directory: string, icon: string | undefined): void {
writeCache(directory, "icon", icon)
}
/** Clear all cached metadata for a directory */
export function clearDirCache(directory: string): void {
clearCache(directory)
}
+54
View File
@@ -0,0 +1,54 @@
export interface RetryOptions {
attempts?: number
delay?: number
factor?: number
maxDelay?: number
retryIf?: (error: unknown) => boolean
}
const TRANSIENT_MESSAGES = [
"load failed",
"network connection was lost",
"network request failed",
"failed to fetch",
"econnreset",
"econnrefused",
"etimedout",
"socket hang up",
"opencode api unavailable",
"503",
"502",
]
function isTransientError(error: unknown): boolean {
if (!error) return false
const message = String(error instanceof Error ? error.message : error).toLowerCase()
if (TRANSIENT_MESSAGES.some((m) => message.includes(m))) return true
// SDK errors from HTTP 502/503 responses (VS Code bridge returns these before OpenCode is ready)
const status = (error as { status?: number })?.status
if (status === 502 || status === 503) return true
return false
}
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const {
attempts = 3,
delay = 500,
factor = 2,
maxDelay = 10000,
retryIf = isTransientError,
} = options
let lastError: unknown
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
if (attempt === attempts - 1 || !retryIf(error)) throw error
const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay)
await new Promise((resolve) => setTimeout(resolve, wait))
}
}
throw lastError
}
+67
View File
@@ -0,0 +1,67 @@
// ---------------------------------------------------------------------------
// Payload sanitization — strip oversized diff snapshot fields client-side.
//
// OpenCode session objects carry summary.diffs[].before/after with full file
// contents. The UI never uses these fields but they waste browser memory and
// can crash tabs for large sessions.
//
// Applied at two points:
// 1. Event reducer — session.created/session.updated events
// 2. Message loading — fetchMessages response
// ---------------------------------------------------------------------------
import type { Session, Message } from "@opencode-ai/sdk/v2/client"
type DiffEntry = {
file?: string
status?: string
additions?: number
deletions?: number
before?: string
after?: string
}
type SessionSummary = {
diffs?: DiffEntry[]
[key: string]: unknown
}
/** Strip before/after from summary.diffs on a session object */
export function stripSessionDiffSnapshots(session: Session): Session {
const summary = (session as { summary?: SessionSummary }).summary
if (!summary?.diffs || !Array.isArray(summary.diffs)) return session
let changed = false
const stripped = summary.diffs.map((d) => {
if (d && (typeof d.before === "string" || typeof d.after === "string")) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { before: _before, after: _after, ...rest } = d
changed = true
return rest
}
return d
})
if (!changed) return session
return { ...session, summary: { ...summary, diffs: stripped } } as Session
}
/** Strip before/after from summary.diffs on a message object */
export function stripMessageDiffSnapshots(message: Message): Message {
const summary = (message as { summary?: SessionSummary }).summary
if (!summary?.diffs || !Array.isArray(summary.diffs)) return message
let changed = false
const stripped = summary.diffs.map((d) => {
if (d && (typeof d.before === "string" || typeof d.after === "string")) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { before: _before, after: _after, ...rest } = d
changed = true
return rest
}
return d
})
if (!changed) return message
return { ...message, summary: { ...summary, diffs: stripped } } as Message
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Selection Store — per-session model, agent, and variant selections.
* Extracted from session-ui-store for subscription isolation.
*/
import { create } from "zustand"
export type SelectionState = {
sessionModelSelections: Map<string, { providerId: string; modelId: string }>
sessionAgentSelections: Map<string, string>
sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>
lastUsedProvider: { providerID: string; modelID: string } | null
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void
getSessionModelSelection: (sessionId: string) => { providerId: string; modelId: string } | null
saveSessionAgentSelection: (sessionId: string, agentName: string) => void
getSessionAgentSelection: (sessionId: string) => string | null
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined
}
// In-memory variant storage (not persisted)
const agentModelVariantSelections = new Map<string, Map<string, Map<string, string>>>()
export const useSelectionStore = create<SelectionState>()((set, get) => ({
sessionModelSelections: new Map(),
sessionAgentSelections: new Map(),
sessionAgentModelSelections: new Map(),
lastUsedProvider: null,
saveSessionModelSelection: (sessionId, providerId, modelId) =>
set((s) => {
const map = new Map(s.sessionModelSelections)
map.set(sessionId, { providerId, modelId })
return { sessionModelSelections: map, lastUsedProvider: { providerID: providerId, modelID: modelId } }
}),
getSessionModelSelection: (sessionId) => get().sessionModelSelections.get(sessionId) ?? null,
saveSessionAgentSelection: (sessionId, agentName) =>
set((s) => {
if (s.sessionAgentSelections.get(sessionId) === agentName) return s
const map = new Map(s.sessionAgentSelections)
map.set(sessionId, agentName)
return { sessionAgentSelections: map }
}),
getSessionAgentSelection: (sessionId) => get().sessionAgentSelections.get(sessionId) ?? null,
saveAgentModelForSession: (sessionId, agentName, providerId, modelId) =>
set((s) => {
const existing = s.sessionAgentModelSelections.get(sessionId)?.get(agentName)
if (existing?.providerId === providerId && existing?.modelId === modelId) return s
const outer = new Map(s.sessionAgentModelSelections)
const inner = new Map(outer.get(sessionId) ?? new Map())
inner.set(agentName, { providerId, modelId })
outer.set(sessionId, inner)
return { sessionAgentModelSelections: outer }
}),
getAgentModelForSession: (sessionId, agentName) =>
get().sessionAgentModelSelections.get(sessionId)?.get(agentName) ?? null,
saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => {
if (!variant) return
const key = `${providerId}/${modelId}`
let agentMap = agentModelVariantSelections.get(sessionId)
if (!agentMap) {
agentMap = new Map()
agentModelVariantSelections.set(sessionId, agentMap)
}
let modelMap = agentMap.get(agentName)
if (!modelMap) {
modelMap = new Map()
agentMap.set(agentName, modelMap)
}
modelMap.set(key, variant)
},
getAgentModelVariantForSession: (sessionId, agentName, providerId, modelId) => {
const key = `${providerId}/${modelId}`
return agentModelVariantSelections.get(sessionId)?.get(agentName)?.get(key)
},
}))
+586
View File
@@ -0,0 +1,586 @@
/**
* Session actions — SDK-calling operations for session management.
* Replaces the action methods from the old useSessionStore.
*/
import type { OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import { useSessionUIStore } from "./session-ui-store"
import { useInputStore } from "./input-store"
import type { DirectoryStore } from "./child-store"
import type { StoreApi } from "zustand"
import { opencodeClient } from "@/lib/opencode/client"
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
// Reference set by SyncProvider — allows actions to access SDK and stores
let _sdk: OpencodeClient | null = null
let _childStores: { ensureChild: (dir: string) => StoreApi<DirectoryStore> } | null = null
let _getDirectory: () => string = () => ""
let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null
let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null
export function setActionRefs(
sdk: OpencodeClient,
childStores: { ensureChild: (dir: string) => StoreApi<DirectoryStore> },
getDirectory: () => string,
) {
_sdk = sdk
_childStores = childStores
_getDirectory = getDirectory
}
export function setOptimisticRefs(
add: (input: { sessionID: string; message: Message; parts: Part[] }) => void,
remove: (input: { sessionID: string; messageID: string }) => void,
) {
_optimisticAdd = add
_optimisticRemove = remove
}
function sdk() {
if (!_sdk) throw new Error("SDK not initialized — is SyncProvider mounted?")
return _sdk
}
function dirStore() {
if (!_childStores) throw new Error("Child stores not initialized")
const d = _getDirectory()
if (!d) throw new Error("No current directory")
return _childStores.ensureChild(d)
}
function dir() {
return _getDirectory() || undefined
}
function getSessionDirectory(sessionId: string): string | undefined {
return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir()
}
function getDirectoryStore(directory?: string) {
if (!_childStores) throw new Error("Child stores not initialized")
const resolvedDirectory = directory || _getDirectory()
if (!resolvedDirectory) throw new Error("No current directory")
return _childStores.ensureChild(resolvedDirectory)
}
function getSessionReplyClient(sessionId?: string): OpencodeClient {
const directory = sessionId
? useSessionUIStore.getState().getDirectoryForSession(sessionId)
: null
if (directory) {
return opencodeClient.getScopedSdkClient(directory)
}
return sdk()
}
// ---------------------------------------------------------------------------
// Session CRUD
// ---------------------------------------------------------------------------
export async function createSession(
title?: string,
directoryOverride?: string | null,
parentID?: string | null,
): Promise<Session | null> {
try {
const result = await sdk().session.create({
directory: directoryOverride ?? dir(),
title,
parentID: parentID ?? undefined,
})
const session = result.data
if (!session) return null
const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory)
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
useGlobalSessionsStore.getState().upsertSession(session)
return session
} catch (error) {
console.error("[session-actions] createSession failed", error)
return null
}
}
/** Optimistically remove a session from the child store list. Returns previous list for rollback. */
function optimisticRemoveSession(sessionId: string, directory?: string): Session[] | null {
const store = getDirectoryStore(directory)
const current = store.getState()
const sessions = [...current.session]
const result = Binary.search(sessions, sessionId, (s) => s.id)
if (result.found) {
const snapshot = current.session
sessions.splice(result.index, 1)
store.setState({ session: sessions })
return snapshot
}
return null
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId)
// Remove from UI immediately, rollback on error
const snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) {
ui.setCurrentSession(null)
}
try {
await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory })
useGlobalSessionsStore.getState().removeSessions([sessionId])
return true
} catch (error) {
console.error("[session-actions] deleteSession failed", error)
if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot })
return false
}
}
/** Delete a session specifying which directory it lives in. Used by agent groups for cross-directory deletes. */
export async function deleteSessionInDirectory(sessionId: string, directory: string): Promise<boolean> {
if (!_childStores) return false
const store = _childStores.ensureChild(directory)
const current = store.getState()
const sessions = [...current.session]
const result = Binary.search(sessions, sessionId, (s) => s.id)
let snapshot: Session[] | null = null
if (result.found) {
snapshot = current.session
sessions.splice(result.index, 1)
store.setState({ session: sessions })
}
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
try {
await sdk().session.delete({ sessionID: sessionId, directory })
useGlobalSessionsStore.getState().removeSessions([sessionId])
return true
} catch (error) {
console.error("[session-actions] deleteSessionInDirectory failed", error)
if (snapshot) store.setState({ session: snapshot })
return false
}
}
export async function archiveSession(sessionId: string): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId)
const snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) {
ui.setCurrentSession(null)
}
try {
const archivedAt = Date.now()
await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, time: { archived: archivedAt } })
useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt)
return true
} catch (error) {
console.error("[session-actions] archiveSession failed", error)
if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot })
return false
}
}
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, title })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
}
export async function shareSession(sessionId: string): Promise<Session | null> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
return result.data ?? null
}
export async function unshareSession(sessionId: string): Promise<Session | null> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
return result.data ?? null
}
// ---------------------------------------------------------------------------
// Optimistic message send — insert user message before API call, rollback on error
// ---------------------------------------------------------------------------
// ID generator matching OpenCode's Identifier.ascending format.
// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62.
// This ensures client-generated IDs sort correctly with server-generated ones.
let lastIdTimestamp = 0
let idCounter = 0
function ascendingId(prefix: string): string {
const now = Date.now()
if (now !== lastIdTimestamp) {
lastIdTimestamp = now
idCounter = 0
}
idCounter += 1
const value = BigInt(now) * BigInt(0x1000) + BigInt(idCounter)
const bytes = new Uint8Array(6)
for (let i = 0; i < 6; i++) {
bytes[i] = Number((value >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
let hex = ""
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, "0")
}
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let rand = ""
for (let i = 0; i < 14; i++) {
rand += chars[Math.floor(Math.random() * 62)]
}
return `${prefix}_${hex}${rand}`
}
/**
* Wraps an async send operation with optimistic user-message insertion.
* Uses useSync()'s optimistic infrastructure — message + parts are inserted
* into the store AND registered in the shadow Map. mergeOptimisticPage
* handles deduplication when the server echoes back the real message.
*/
export async function optimisticSend(input: {
sessionId: string
content: string
providerID: string
modelID: string
agent?: string
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
/** The actual API call — receives the optimistic messageID so the server can use the same ID */
send: (messageID: string) => Promise<void>
}): Promise<void> {
if (!_optimisticAdd || !_optimisticRemove) {
throw new Error("Optimistic refs not set — is useSync() mounted?")
}
const store = dirStore()
const messageID = ascendingId("msg")
const textPartId = ascendingId("prt")
const optimisticParts: Part[] = [
{ id: textPartId, type: "text", text: input.content } as Part,
]
if (input.files) {
for (const f of input.files) {
optimisticParts.push({ id: ascendingId("prt"), type: "file", mime: f.mime, url: f.url, filename: f.filename } as Part)
}
}
const optimisticMessage = {
id: messageID,
role: "user" as const,
sessionID: input.sessionId,
parentID: "",
modelID: input.modelID,
providerID: input.providerID,
system: "",
agent: input.agent ?? "",
model: `${input.providerID}/${input.modelID}`,
metadata: {} as Record<string, unknown>,
time: { created: Date.now(), completed: 0 },
} as unknown as Message
// Insert into store + register in shadow Map (for mergeOptimisticPage cleanup)
_optimisticAdd({
sessionID: input.sessionId,
message: optimisticMessage,
parts: optimisticParts,
})
// Set busy status
const current = store.getState()
store.setState({
session_status: {
...current.session_status,
[input.sessionId]: { type: "busy" as const },
},
})
try {
await input.send(messageID)
} catch (error) {
// Rollback via optimistic infrastructure
_optimisticRemove({
sessionID: input.sessionId,
messageID,
})
const s = store.getState()
store.setState({
session_status: {
...s.session_status,
[input.sessionId]: { type: "idle" as const },
},
})
throw error
}
}
// ---------------------------------------------------------------------------
// Abort
// ---------------------------------------------------------------------------
export async function abortCurrentOperation(sessionId: string): Promise<void> {
try {
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
} catch (error) {
console.error("[session-actions] abort failed", error)
}
}
// ---------------------------------------------------------------------------
// Permissions
// ---------------------------------------------------------------------------
export async function respondToPermission(
sessionId: string,
requestId: string,
response: "once" | "always" | "reject",
): Promise<void> {
const result = await getSessionReplyClient(sessionId).permission.reply({
requestID: requestId,
reply: response,
})
if (!result.data) {
throw new Error("Permission reply failed")
}
}
export async function dismissPermission(
sessionId: string,
requestId: string,
): Promise<void> {
const result = await getSessionReplyClient(sessionId).permission.reply({
requestID: requestId,
reply: "reject",
})
if (!result.data) {
throw new Error("Permission dismissal failed")
}
}
// ---------------------------------------------------------------------------
// Questions
// ---------------------------------------------------------------------------
export async function respondToQuestion(
sessionId: string,
requestId: string,
answers: string[] | string[][],
): Promise<void> {
const result = await getSessionReplyClient(sessionId).question.reply({
requestID: requestId,
answers: answers as Array<Array<string>>,
})
if (!result.data) {
throw new Error("Question reply failed")
}
}
export async function rejectQuestion(
sessionId: string,
requestId: string,
): Promise<void> {
const result = await getSessionReplyClient(sessionId).question.reject({
requestID: requestId,
})
if (!result.data) {
throw new Error("Question rejection failed")
}
}
// ---------------------------------------------------------------------------
// Message history
// ---------------------------------------------------------------------------
/**
* Revert to a specific user message.
*
* 1. Abort if session is busy
* 2. Extract text from the target message for prompt restoration
* 3. Optimistically set revert marker so messages hide immediately
* 4. Call SDK session.revert() and merge returned session
* 5. Set pendingInputText so the reverted message text appears in the input
*/
export async function revertToMessage(sessionId: string, messageId: string): Promise<void> {
const store = dirStore()
const state = store.getState()
// Abort if busy before mutating session state
const status = state.session_status[sessionId]
if (status && status.type !== "idle") {
try {
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
} catch {
// ignore abort errors
}
}
// Extract message text for prompt restoration
const messages = state.message[sessionId] ?? []
const targetMsg = messages.find((m) => m.id === messageId)
let messageText = ""
if (targetMsg && targetMsg.role === "user") {
const parts = state.part[messageId] ?? []
const textParts = parts.filter((p) => p.type === "text")
messageText = textParts
.map((p: Record<string, unknown>) => (p as { text?: string }).text || (p as { content?: string }).content || "")
.join("\n")
.trim()
}
// Optimistically remove reverted messages + set marker
const prevRevert = (() => {
const s = state.session.find((s) => s.id === sessionId)
return (s as Session & { revert?: unknown })?.revert
})()
const sessions = [...state.session]
const sessionIdx = sessions.findIndex((s) => s.id === sessionId)
// Remove messages at and after the revert point from the store
const prevMessages = state.message[sessionId] ?? []
const prevPart = { ...state.part }
const keptMessages = prevMessages.filter((m) => m.id < messageId)
const removedMessages = prevMessages.filter((m) => m.id >= messageId)
for (const m of removedMessages) {
delete prevPart[m.id]
}
const patch: Record<string, unknown> = {
message: { ...state.message, [sessionId]: keptMessages },
part: prevPart,
}
if (sessionIdx >= 0) {
sessions[sessionIdx] = { ...sessions[sessionIdx], revert: { messageID: messageId } } as Session
patch.session = sessions
}
store.setState(patch)
// Restore reverted message text to input
if (messageText) {
useInputStore.setState({
pendingInputText: messageText,
pendingInputMode: "replace" as const,
})
}
// Call SDK and merge authoritative result into store
try {
const result = await sdk().session.revert({ sessionID: sessionId, directory: dir(), messageID: messageId })
if (result.data) {
const current = store.getState()
const updated = [...current.session]
const idx = updated.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
updated[idx] = result.data
store.setState({ session: updated })
}
}
} catch (err) {
// Rollback: restore removed messages + revert marker
const current = store.getState()
const rollback = [...current.session]
const idx = rollback.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
rollback[idx] = { ...rollback[idx], revert: prevRevert } as Session
}
store.setState({
session: rollback,
message: { ...current.message, [sessionId]: prevMessages },
part: { ...current.part, ...Object.fromEntries(removedMessages.map((m) => [m.id, state.part[m.id] ?? []])) },
})
throw err
}
}
/**
* Unrevert — restore all previously reverted messages.
* Restore all previously reverted messages. Aborts if busy, merges result.
*/
export async function unrevertSession(sessionId: string): Promise<void> {
const store = dirStore()
const state = store.getState()
// Abort if busy
const status = state.session_status[sessionId]
if (status && status.type !== "idle") {
try {
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
} catch {
// ignore
}
}
const result = await sdk().session.unrevert({ sessionID: sessionId, directory: dir() })
if (result.data) {
const current = store.getState()
const sessions = [...current.session]
const idx = sessions.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
sessions[idx] = result.data
store.setState({ session: sessions })
}
}
}
/**
* Fork from a user message.
*
* 1. Extract text from the message for input restoration
* 2. Call SDK session.fork()
* 3. Insert the new session into the child store (so sidebar updates immediately)
* 4. Switch to new session and set pending input text
*/
export async function forkFromMessage(sessionId: string, messageId: string): Promise<void> {
const store = dirStore()
const state = store.getState()
// Extract message text for input restoration
const parts = state.part[messageId] ?? []
let messageText = ""
const textParts = parts.filter((p) => p.type === "text")
messageText = textParts
.map((p: Part) => ((p as Record<string, unknown>).text as string) || ((p as Record<string, unknown>).content as string) || "")
.join("\n")
.trim()
const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId })
if (!result.data) return
const forkedSession = result.data
// Insert new session into child store so sidebar updates immediately
const current = store.getState()
const sessions = [...current.session]
const searchResult = Binary.search(sessions, forkedSession.id, (s) => s.id)
if (!searchResult.found) {
sessions.splice(searchResult.index, 0, forkedSession)
store.setState({ session: sessions })
}
// Switch to new session
useSessionUIStore.getState().setCurrentSession(forkedSession.id)
// Restore forked message text to input
if (messageText) {
useInputStore.setState({
pendingInputText: messageText,
pendingInputMode: "replace" as const,
})
}
}
+61
View File
@@ -0,0 +1,61 @@
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
}
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
if (stale.size === 0) return
for (const key of Object.keys(store.part ?? {})) {
const parts = store.part[key]
if (!parts?.some((part) => stale.has((part as { sessionID?: string })?.sessionID ?? "")))
continue
delete store.part[key]
}
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.todo[sessionID]
delete store.session_diff[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
}
}
export function pickSessionCacheEvictions(input: {
seen: Set<string>
keep: string
limit: number
preserve?: Iterable<string>
}) {
const stale: string[] = []
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
input.seen.add(input.keep)
for (const id of input.seen) {
if (input.seen.size - stale.length <= input.limit) break
if (keep.has(id)) continue
stale.push(id)
}
for (const id of stale) {
input.seen.delete(id)
}
return stale
}
@@ -0,0 +1,113 @@
/**
* Session prefetch TTL cache — prevents redundant session fetches
* within a short window. Port of OpenCode's session-prefetch.ts.
*
* Tracks: last fetch time, pagination cursor, completeness.
* Version counter invalidates stale inflight requests after eviction.
*/
const SESSION_PREFETCH_TTL = 15_000
type Meta = {
limit: number
cursor?: string
complete: boolean
at: number
}
const compositeKey = (directory: string, sessionID: string) =>
`${directory}\n${sessionID}`
const cache = new Map<string, Meta>()
const inflight = new Map<string, Promise<Meta | undefined>>()
const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0
/** Check if a prefetch/sync can be skipped (recently fetched). */
export function shouldSkipSessionPrefetch(input: {
hasMessages: boolean
info?: Meta
pageSize: number
now?: number
}): boolean {
if (input.hasMessages) {
if (!input.info) return true
if (input.info.complete) return true
if (input.info.limit > input.pageSize) return true
} else {
if (!input.info) return false
}
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
}
export function getSessionPrefetch(directory: string, sessionID: string): Meta | undefined {
return cache.get(compositeKey(directory, sessionID))
}
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
return inflight.get(compositeKey(directory, sessionID))
}
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
return version(compositeKey(directory, sessionID)) === value
}
/** Run a prefetch task with inflight dedup + version tracking. */
export function runSessionPrefetch(input: {
directory: string
sessionID: string
task: (value: number) => Promise<Meta | undefined>
}) {
const id = compositeKey(input.directory, input.sessionID)
const pending = inflight.get(id)
if (pending) return pending
const value = version(id)
const promise = input.task(value).finally(() => {
if (inflight.get(id) === promise) inflight.delete(id)
})
inflight.set(id, promise)
return promise
}
export function setSessionPrefetch(input: {
directory: string
sessionID: string
limit: number
cursor?: string
complete: boolean
at?: number
}) {
cache.set(compositeKey(input.directory, input.sessionID), {
limit: input.limit,
cursor: input.cursor,
complete: input.complete,
at: input.at ?? Date.now(),
})
}
/** Invalidate cache for specific sessions (e.g. after eviction). */
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) {
if (!sessionID) continue
const id = compositeKey(directory, sessionID)
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
/** Invalidate all cache entries for a directory. */
export function clearSessionPrefetchDirectory(directory: string) {
const prefix = `${directory}\n`
const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) {
if (!id.startsWith(prefix)) continue
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
/**
* Streaming lifecycle tracking.
*
* Derives streaming state from the sync child store's session_status and
* message/part updates. Components read this to know which messages are
* currently streaming and their lifecycle phase.
*/
import { create } from "zustand"
import type { Message, SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { State } from "./types"
export type StreamPhase = "streaming" | "cooldown" | "completed"
export type MessageStreamState = {
phase: StreamPhase
startedAt: number
lastUpdateAt: number
completedAt?: number
}
export type StreamingStore = {
/** Currently streaming message per session */
streamingMessageIds: Map<string, string | null>
/** Lifecycle phase per message */
messageStreamStates: Map<string, MessageStreamState>
}
export const useStreamingStore = create<StreamingStore>()(() => ({
streamingMessageIds: new Map(),
messageStreamStates: new Map(),
}))
/**
* Called from the SyncBridge/flush handler when child store state changes.
* Derives streaming state from session_status + messages.
*/
export function updateStreamingState(state: State) {
const now = Date.now()
const nextStreamingIds = new Map<string, string | null>()
const nextStreamStates = new Map(useStreamingStore.getState().messageStreamStates)
let changed = false
for (const [sessionID, status] of Object.entries(state.session_status ?? {})) {
const isBusy = (status as SessionStatus).type === "busy"
const messages = state.message[sessionID]
if (isBusy && messages && messages.length > 0) {
// Find the last assistant message — that's the one streaming
let streamingMsg: Message | null = null
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
streamingMsg = messages[i]
break
}
}
if (streamingMsg) {
const prevId = nextStreamingIds.get(sessionID)
if (prevId !== streamingMsg.id) changed = true
nextStreamingIds.set(sessionID, streamingMsg.id)
const existing = nextStreamStates.get(streamingMsg.id)
if (!existing || existing.phase !== "streaming") {
nextStreamStates.set(streamingMsg.id, {
phase: "streaming",
startedAt: existing?.startedAt ?? now,
lastUpdateAt: now,
})
changed = true
} else if (existing.lastUpdateAt !== now) {
nextStreamStates.set(streamingMsg.id, {
...existing,
lastUpdateAt: now,
})
changed = true
}
}
} else {
// Session is idle — check if we had a streaming message
const prev = useStreamingStore.getState().streamingMessageIds.get(sessionID)
if (prev) {
nextStreamingIds.set(sessionID, null)
const existing = nextStreamStates.get(prev)
if (existing && existing.phase === "streaming") {
// Transition to cooldown then completed
nextStreamStates.set(prev, {
...existing,
phase: "completed",
completedAt: now,
})
changed = true
}
}
}
}
// Also mark completed any streaming messages for sessions no longer in status
const currentIds = useStreamingStore.getState().streamingMessageIds
for (const [sessionID, msgId] of currentIds) {
if (msgId && !state.session_status?.[sessionID]) {
const existing = nextStreamStates.get(msgId)
if (existing && existing.phase === "streaming") {
nextStreamStates.set(msgId, {
...existing,
phase: "completed",
completedAt: now,
})
changed = true
}
nextStreamingIds.set(sessionID, null)
}
}
if (changed) {
useStreamingStore.setState({
streamingMessageIds: nextStreamingIds,
messageStreamStates: nextStreamStates,
})
}
}
// Selectors
export const selectStreamingMessageId = (sessionID: string) =>
(state: StreamingStore) => state.streamingMessageIds.get(sessionID) ?? null
export const selectMessageStreamState = (messageID: string) =>
(state: StreamingStore) => state.messageStreamStates.get(messageID) ?? null
export const selectIsStreaming = (sessionID: string) =>
(state: StreamingStore) => state.streamingMessageIds.get(sessionID) != null
+143
View File
@@ -0,0 +1,143 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { useCallback } from "react"
import { useSyncSDK } from "./sync-context"
import { useDirectoryStore } from "./sync-context"
import { useSync } from "./use-sync"
// ---------------------------------------------------------------------------
// Ascending ID generator — monotonic timestamp + sequence counter
// ---------------------------------------------------------------------------
let counter = 0
function ascending(prefix: string): string {
const now = Date.now()
const seq = (counter++ % 1000).toString().padStart(3, "0")
return `${prefix}_${now}${seq}`
}
// ---------------------------------------------------------------------------
// Prompt submission with optimistic updates
// Prompt submission with optimistic message insertion
// ---------------------------------------------------------------------------
export type SubmitInput = {
sessionID: string
text: string
parts?: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
command?: { name: string; arguments: string }
images?: Array<{ id?: string; type: "file"; mime: string; url: string; filename: string }>
}
export function usePromptSubmit() {
const sdk = useSyncSDK()
const store = useDirectoryStore()
const sync = useSync()
const submit = useCallback(
async (input: SubmitInput) => {
const messageID = ascending("message")
// Build optimistic user message
const message: Message = {
id: messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: input.model,
variant: input.variant,
} as Message
// Build optimistic parts
const textPart: Part = {
id: ascending("part"),
sessionID: input.sessionID,
messageID,
type: "text",
text: input.text,
} as Part
const optimisticParts: Part[] = [textPart, ...(input.parts ?? [])]
// Set busy status optimistically
store.setState((prev) => ({
...prev,
session_status: {
...prev.session_status,
[input.sessionID]: { type: "busy" },
},
}))
// Add optimistic message immediately
sync.optimistic.add({
sessionID: input.sessionID,
message,
parts: optimisticParts,
})
try {
if (input.command) {
// Slash command
await sdk.session.command({
sessionID: input.sessionID,
command: input.command.name,
arguments: input.command.arguments,
agent: input.agent,
model: `${input.model.providerID}/${input.model.modelID}`,
variant: input.variant,
parts: input.images,
})
} else {
// Regular prompt
const requestParts: Array<{ id: string; type: "text"; text: string }
| { id: string; type: "file"; mime: string; url: string; filename?: string }> = [
{ id: textPart.id, type: "text" as const, text: input.text },
]
if (input.images) {
for (const img of input.images) {
requestParts.push({
id: img.id ?? ascending("part"),
type: "file" as const,
mime: img.mime,
url: img.url,
filename: img.filename,
})
}
}
await sdk.session.promptAsync({
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
messageID,
parts: requestParts,
variant: input.variant,
})
}
return true
} catch (error) {
// Revert optimistic on failure
sync.optimistic.remove({
sessionID: input.sessionID,
messageID,
})
// Reset status
store.setState((prev) => ({
...prev,
session_status: {
...prev.session_status,
[input.sessionID]: { type: "idle" },
},
}))
throw error
}
},
[sdk, store, sync],
)
return submit
}
+639
View File
@@ -0,0 +1,639 @@
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react"
import type { Event, Message, Part } from "@opencode-ai/sdk/v2/client"
import type { StoreApi } from "zustand"
import { useStore } from "zustand"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createEventPipeline } from "./event-pipeline"
import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer"
import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store"
import { ChildStoreManager, type DirectoryStore } from "./child-store"
import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
import { retry } from "./retry"
import { updateStreamingState } from "./streaming"
import { setActionRefs } from "./session-actions"
import { setSyncRefs } from "./sync-refs"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { autoRespondsPermission, normalizeDirectory } from "@/stores/utils/permissionAutoAccept"
import { appendNotification } from "./notification-store"
import type { State } from "./types"
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest } from "@/types/permission"
import type { QuestionRequest } from "@/types/question"
import { create } from "zustand"
import * as sessionActions from "./session-actions"
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
type SyncSystem = {
childStores: ChildStoreManager
sdk: OpencodeClient
directory: string
}
const SyncContext = createContext<SyncSystem | null>(null)
function useSyncSystem() {
const ctx = useContext(SyncContext)
if (!ctx) throw new Error("useSyncSystem must be used within <SyncProvider>")
return ctx
}
// ---------------------------------------------------------------------------
// Event handler — applies one SSE event at a time to the live store.
// Each event reads live state, creates a shallow draft, applies, writes back.
// React 18 batches synchronous setState calls automatically.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Global session status store — cross-directory status tracking.
//
// OpenCode isolates sessions behind project navrails, so per-directory
// session_status is sufficient. OpenChamber shows all sessions in one sidebar,
// so we need a global view. Updated from handleEvent on every session.status.
// ---------------------------------------------------------------------------
interface GlobalSessionStatusStore {
statuses: Record<string, SessionStatus>
}
const useGlobalSessionStatusStore = create<GlobalSessionStatusStore>(() => ({
statuses: {},
}))
function setGlobalSessionStatus(sessionId: string, status: SessionStatus) {
const current = useGlobalSessionStatusStore.getState().statuses
if (current[sessionId] === status) return
useGlobalSessionStatusStore.setState({
statuses: { ...current, [sessionId]: status },
})
}
/** Read status for a session across all directories */
export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined {
return useGlobalSessionStatusStore((s) => s.statuses[sessionId])
}
/** Read all session statuses (for sidebar) */
export function useAllSessionStatuses(): Record<string, SessionStatus> {
return useGlobalSessionStatusStore((s) => s.statuses)
}
// Boot debounce — suppresses redundant refresh/re-bootstrap events during startup.
let bootingRoot = false
let bootedAt = 0
const BOOT_DEBOUNCE_MS = 1500
// Module-level refs for notification viewed check.
// Used to determine if user is currently viewing the session when a notification arrives.
let _activeDirectory = ""
let _activeSession = ""
export function setActiveSession(directory: string, sessionId: string) {
_activeDirectory = directory
_activeSession = sessionId
}
function isViewedInCurrentSession(directory: string, sessionId?: string): boolean {
if (!_activeDirectory || !_activeSession || !sessionId) return false
if (directory !== _activeDirectory) return false
return sessionId === _activeSession
}
function isRecentBoot() {
return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS
}
function handleEvent(
directory: string,
payload: Event,
childStores: ChildStoreManager,
) {
// Global events
if (directory === "global" || !directory) {
const recent = isRecentBoot()
const result = reduceGlobalEvent(payload)
if (!result) return
if (result.type === "refresh") {
// Suppress refresh during/shortly after bootstrap
if (!recent) {
useGlobalSyncStore.setState({ reload: "pending" })
}
} else if (result.type === "project") {
const current = useGlobalSyncStore.getState()
useGlobalSyncStore.setState({
projects: applyGlobalProject(current, result.project).projects,
})
}
// On server.connected / global.disposed, re-bootstrap all directories
// but only if not during recent boot
if (payload.type === "server.connected" || payload.type === "global.disposed") {
if (!recent) {
for (const dir of childStores.children.keys()) {
const store = childStores.getChild(dir)
if (store && store.getState().status !== "loading") {
// Mark as loading to trigger re-bootstrap
store.setState({ status: "loading" as const })
childStores.ensureChild(dir)
}
}
}
}
return
}
// Directory events
const store = childStores.getChild(directory)
if (!store) {
// Try as global event for unknown directories
const result = reduceGlobalEvent(payload)
if (result?.type === "refresh") {
useGlobalSyncStore.setState({ reload: "pending" })
} else if (result?.type === "project") {
const current = useGlobalSyncStore.getState()
useGlobalSyncStore.setState({
projects: applyGlobalProject(current, result.project).projects,
})
}
return
}
childStores.mark(directory)
// Notification dispatch for session turn-complete and error events.
// These are NOT handled by the event reducer — only the notification store.
if (payload.type === "session.idle" || payload.type === "session.error") {
const props = payload.properties as { sessionID?: string; error?: { message?: string; code?: string } }
const sessionID = props.sessionID
// Skip subtask sessions — only top-level sessions generate notifications
const storeState = store.getState()
const session = storeState.session.find((s) => s.id === sessionID)
if (session && (session as { parentID?: string }).parentID) {
// subtask — skip notification
} else if (sessionID) {
appendNotification({
directory,
session: sessionID,
time: Date.now(),
viewed: isViewedInCurrentSession(directory, sessionID),
...(payload.type === "session.error"
? { type: "error" as const, error: props.error }
: { type: "turn-complete" as const }),
})
}
}
// Read live state, create targeted draft cloning ONLY fields the event
// type will mutate. This preserves reference identity for untouched slices
// so Zustand selectors skip re-renders for unrelated subscribers.
const current = store.getState()
const draft: State = { ...current }
switch (payload.type) {
case "session.created":
case "session.updated":
case "session.deleted":
draft.session = [...current.session]
draft.permission = { ...current.permission }
draft.todo = { ...current.todo }
draft.part = { ...current.part }
break
case "session.diff":
draft.session_diff = { ...current.session_diff }
break
case "session.status":
draft.session_status = { ...(current.session_status ?? {}) }
break
case "todo.updated":
draft.todo = { ...current.todo }
break
case "message.updated":
draft.message = { ...current.message }
break
case "message.removed":
draft.message = { ...current.message }
draft.part = { ...current.part }
break
case "message.part.updated":
case "message.part.removed":
case "message.part.delta":
draft.part = { ...current.part }
break
case "vcs.branch.updated":
break
case "permission.asked":
case "permission.replied":
draft.permission = { ...current.permission }
break
case "question.asked":
case "question.replied":
case "question.rejected":
draft.question = { ...current.question }
break
case "lsp.updated":
draft.lsp = [...current.lsp]
break
default:
break
}
if (applyDirectoryEvent(draft, payload)) {
store.setState(draft)
}
// Update global session status for cross-directory sidebar visibility
if (payload.type === "session.status") {
const props = payload.properties as { sessionID: string; status: SessionStatus }
setGlobalSessionStatus(props.sessionID, props.status)
}
if (payload.type === "permission.asked") {
const normalizedDirectory = normalizeDirectory(directory)
if (!normalizedDirectory) {
return
}
const permission = payload.properties as PermissionRequest
const sessions = store.getState().session
const autoAccept = usePermissionStore.getState().autoAccept
if (autoRespondsPermission({ autoAccept, sessions, sessionID: permission.sessionID, directory: normalizedDirectory })) {
void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined)
}
}
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function SyncProvider(props: {
sdk: OpencodeClient
directory: string
children: React.ReactNode
}) {
const childStoresRef = useRef<ChildStoreManager | null>(null)
if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager()
const childStores = childStoresRef.current
const system = useMemo<SyncSystem>(
() => ({
childStores,
sdk: props.sdk,
directory: props.directory,
}),
[childStores, props.sdk, props.directory],
)
// Configure child store manager
useEffect(() => {
const bootingDirs = new Set<string>()
childStores.configure({
onBootstrap: (directory) => {
if (bootingDirs.has(directory)) return
bootingDirs.add(directory)
const store = childStores.getChild(directory)
if (!store) return
const runBootstrap = async (attempt: number) => {
const globalState = useGlobalSyncStore.getState()
await bootstrapDirectory({
directory,
sdk: props.sdk,
getState: () => store.getState(),
set: (patch) => {
store.setState(patch)
if (patch.session_status) {
const current = useGlobalSessionStatusStore.getState().statuses
const merged = { ...current, ...patch.session_status }
useGlobalSessionStatusStore.setState({ statuses: merged })
}
},
global: {
config: globalState.config,
projects: globalState.projects,
providers: globalState.providers,
},
loadSessions: (dir) => retry(async () => {
const result = await props.sdk.session.list({
directory: dir,
roots: true,
limit: 50,
})
// SDK returns { error } instead of { data } on non-ok responses (503).
// Throw so retry() retries and allSettled marks it as rejected.
if ((result as { error?: unknown }).error) {
throw new Error("session.list failed: " + String((result as { error?: unknown }).error))
}
const sessions = (result.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
store.setState({ session: sessions, sessionTotal: sessions.length, limit: Math.max(sessions.length, 50) })
}),
})
// VS Code race: if sessions are still empty after bootstrap, OpenCode
// wasn't ready yet (bridge returned 503). Retry a few times.
const state = store.getState()
if (state.session.length === 0 && attempt < 5) {
await new Promise((r) => setTimeout(r, 2000))
store.setState({ status: "loading" as const })
await runBootstrap(attempt + 1)
}
}
runBootstrap(0).finally(() => {
bootingDirs.delete(directory)
})
},
onDispose: (directory) => {
bootingDirs.delete(directory)
},
isBooting: (directory) => bootingDirs.has(directory),
isLoadingSessions: () => false,
})
}, [childStores, props.sdk])
// Bootstrap global state — set bootingRoot/bootedAt to suppress
// redundant refresh events during startup
useEffect(() => {
bootingRoot = true
const globalActions = useGlobalSyncStore.getState().actions
bootstrapGlobal(props.sdk, globalActions.set)
.then(() => {
bootedAt = Date.now()
})
.finally(() => {
bootingRoot = false
})
}, [props.sdk])
// Event pipeline — created once per mount. No class, no start/stop.
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
useEffect(() => {
const { cleanup } = createEventPipeline({
sdk: props.sdk,
onEvent: (directory, payload) => {
handleEvent(directory, payload, childStores)
},
})
return cleanup
}, [props.sdk, childStores])
// Ensure current directory's child store exists
useEffect(() => {
if (props.directory) {
childStores.ensureChild(props.directory)
}
}, [props.directory, childStores])
// Set refs so non-React code (session-actions, session-ui-store) can access sync state
useEffect(() => {
setSyncRefs(props.sdk, childStores, props.directory)
setActionRefs(
props.sdk,
childStores,
() => opencodeClient.getDirectory() || props.directory,
)
}, [props.sdk, props.directory, childStores])
// Subscribe to child store for streaming state derivation
useEffect(() => {
if (!props.directory) return
const store = childStores.getChild(props.directory)
if (!store) return
const unsubscribe = store.subscribe((state) => {
updateStreamingState(state)
})
return unsubscribe
}, [props.directory, childStores])
return <SyncContext.Provider value={system}>{props.children}</SyncContext.Provider>
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
/** Access the global sync store */
export function useGlobalSync() {
return useGlobalSyncStore()
}
/** Access the global sync store with a selector */
export function useGlobalSyncSelector<T>(selector: (state: GlobalSyncStore) => T): T {
return useGlobalSyncStore(selector)
}
/** Get the child store for a directory (defaults to current) */
export function useDirectoryStore(directory?: string): StoreApi<DirectoryStore> {
const system = useSyncSystem()
const dir = directory ?? system.directory
return system.childStores.ensureChild(dir)
}
/** Select from the current directory's store */
export function useDirectorySync<T>(selector: (state: State) => T, directory?: string): T {
const store = useDirectoryStore(directory)
return useStore(store, selector)
}
/** Get the revert messageID for a session (if reverted) */
export function useSessionRevertMessageID(sessionID: string, directory?: string): string | undefined {
return useDirectorySync(
useCallback((state: State) => {
const session = state.session.find((s) => s.id === sessionID)
return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID
}, [sessionID]),
directory,
)
}
/** Get session messages for a specific session */
export function useSessionMessages(sessionID: string, directory?: string) {
return useDirectorySync(
useCallback((state: State) => state.message[sessionID] ?? EMPTY_MESSAGES, [sessionID]),
directory,
)
}
/**
* Get visible session messages filters out reverted messages.
* Filters out reverted messages (id >= session.revert.messageID).
*/
export function useVisibleSessionMessages(sessionID: string, directory?: string) {
const messages = useSessionMessages(sessionID, directory)
const revertMessageID = useSessionRevertMessageID(sessionID, directory)
return useMemo(() => {
if (!revertMessageID) return messages
return messages.filter((m) => m.id < revertMessageID)
}, [messages, revertMessageID])
}
/** Get parts for a specific message */
export function useSessionParts(messageID: string, directory?: string) {
return useDirectorySync(
useCallback((state: State) => state.part[messageID] ?? EMPTY_PARTS, [messageID]),
directory,
)
}
/** Get status for a specific session */
export function useSessionStatus(sessionID: string, directory?: string) {
return useDirectorySync(
useCallback((state: State) => state.session_status?.[sessionID], [sessionID]),
directory,
)
}
/** Get permissions for a specific session */
export function useSessionPermissions(sessionID: string, directory?: string) {
return useDirectorySync(
useCallback((state: State) => state.permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS, [sessionID]),
directory,
)
}
/** Get questions for a specific session */
export function useSessionQuestions(sessionID: string, directory?: string) {
return useDirectorySync(
useCallback((state: State) => state.question[sessionID] ?? EMPTY_QUESTION_REQUESTS, [sessionID]),
directory,
)
}
/** Get sessions list for a directory */
export function useSessions(directory?: string) {
return useDirectorySync(
useCallback((state: State) => state.session, []),
directory,
)
}
/** Get the SDK client */
export function useSyncSDK() {
return useSyncSystem().sdk
}
/** Get the current directory */
export function useSyncDirectory() {
return useSyncSystem().directory
}
/** Get the child store manager (for advanced operations) */
export function useChildStoreManager() {
return useSyncSystem().childStores
}
/**
* Get messages for a session in the old {info, parts}[] format.
* Uses visible messages (filtered by revert state).
*
* Uses a ref-stable parts lookup that only triggers re-renders when
* a part array for one of our displayed messages actually changes.
*/
export function useSessionMessageRecords(sessionID: string, directory?: string) {
const messages = useVisibleSessionMessages(sessionID, directory)
const store = useDirectoryStore(directory)
// Track parts with a ref to avoid subscribing to entire state.part map.
// Re-derive only when messages list changes or on store subscription.
const prevPartsRef = useRef<Record<string, Part[]>>({})
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
React.useEffect(() => {
const messageIds = messages.map((m) => m.id)
let timer: ReturnType<typeof setTimeout> | null = null
let pending = false
const flush = () => {
timer = null
pending = false
const state = store.getState()
const prev = prevPartsRef.current
let changed = false
const next: Record<string, Part[]> = {}
for (const id of messageIds) {
const parts = state.part[id] ?? EMPTY_PARTS
// Preserve existing reference if parts haven't changed in the store
next[id] = prev[id] === parts ? prev[id] : parts
if (next[id] !== prev[id]) changed = true
}
if (changed || Object.keys(prev).length !== messageIds.length) {
prevPartsRef.current = next
setPartsSnapshot(next)
}
}
// Initial sync
flush()
// Throttled subscription — batch rapid delta events into ~100ms updates
const unsub = store.subscribe(() => {
if (timer) {
pending = true
return
}
timer = setTimeout(() => {
flush()
if (pending) {
pending = false
timer = setTimeout(flush, 100)
}
}, 100)
})
return () => {
unsub()
if (timer) clearTimeout(timer)
}
}, [messages, store])
return useMemo(
() => messages.map((msg) => ({
info: msg,
parts: partsSnapshot[msg.id] ?? EMPTY_PARTS,
})),
[messages, partsSnapshot],
)
}
/**
* Determines if a session is actively working.
* Checks session_status AND incomplete assistant messages as fallback.
* Returns false when permissions are pending (permission indicator takes priority).
*/
export function useIsSessionWorking(sessionID: string, directory?: string): boolean {
const status = useSessionStatus(sessionID, directory)
const permissions = useSessionPermissions(sessionID, directory)
const messages = useSessionMessages(sessionID, directory)
return useMemo(() => {
// Permissions pending → not "working" (show permission indicator instead)
if (permissions.length > 0) return false
// Check session_status
const statusWorking = status !== undefined && status.type !== "idle"
// Check for incomplete assistant message (fallback if status event delayed)
let hasPendingAssistant = false
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (m.role === "assistant" && typeof (m as { time?: { completed?: number } }).time?.completed !== "number") {
hasPendingAssistant = true
break
}
}
return statusWorking || hasPendingAssistant
}, [status, permissions, messages])
}
const EMPTY_MESSAGES: Message[] = []
const EMPTY_PARTS: Part[] = []
const EMPTY_PERMISSION_REQUESTS: PermissionRequest[] = []
const EMPTY_QUESTION_REQUESTS: QuestionRequest[] = []
+92
View File
@@ -0,0 +1,92 @@
/**
* Sync refs imperative access to sync state from non-React code.
*
* SyncProvider sets these refs on mount. Store actions (session-ui-store,
* session-actions) use them to read child-store domain data without hooks.
*/
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ChildStoreManager } from "./child-store"
import type { State } from "./types"
let _sdk: OpencodeClient | null = null
let _childStores: ChildStoreManager | null = null
let _directory: string = ""
export function setSyncRefs(
sdk: OpencodeClient,
childStores: ChildStoreManager,
directory: string,
) {
_sdk = sdk
_childStores = childStores
_directory = directory
}
export function getSyncSDK(): OpencodeClient {
if (!_sdk) throw new Error("SDK not initialized — is SyncProvider mounted?")
return _sdk
}
export function getSyncChildStores(): ChildStoreManager {
if (!_childStores) throw new Error("ChildStoreManager not initialized — is SyncProvider mounted?")
return _childStores
}
export function getSyncDirectory(): string {
return _directory
}
/** Read current directory's child store state. Returns undefined if not bootstrapped. */
export function getDirectoryState(directory?: string): State | undefined {
const stores = _childStores
if (!stores) return undefined
const dir = directory || _directory
if (!dir) return undefined
return stores.getState(dir)
}
/** Read sessions from current directory's child store */
export function getSyncSessions(directory?: string) {
return getDirectoryState(directory)?.session ?? []
}
/** Read sessions across all initialized child stores */
export function getAllSyncSessions() {
const stores = _childStores
if (!stores) return []
const deduped = new Map<string, State["session"][number]>()
for (const store of stores.children.values()) {
for (const session of store.getState().session) {
if (!session?.id) continue
deduped.set(session.id, session)
}
}
return Array.from(deduped.values())
}
/** Read messages for a session from current directory's child store */
export function getSyncMessages(sessionId: string, directory?: string) {
return getDirectoryState(directory)?.message[sessionId] ?? []
}
/** Read parts for a message from current directory's child store */
export function getSyncParts(messageId: string, directory?: string) {
return getDirectoryState(directory)?.part[messageId] ?? []
}
/** Read session status from current directory's child store */
export function getSyncSessionStatus(sessionId: string, directory?: string) {
return getDirectoryState(directory)?.session_status[sessionId]
}
/** Read permissions for a session from current directory's child store */
export function getSyncPermissions(sessionId: string, directory?: string) {
return getDirectoryState(directory)?.permission[sessionId] ?? []
}
/** Read questions for a session from current directory's child store */
export function getSyncQuestions(sessionId: string, directory?: string) {
return getDirectoryState(directory)?.question[sessionId] ?? []
}
+142
View File
@@ -0,0 +1,142 @@
import type {
Agent,
Command,
Config,
FileDiff,
LspStatus,
McpStatus,
Message,
Part,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
Session,
SessionStatus,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
export type ProjectMeta = {
name?: string
icon?: {
override?: string
color?: string
}
commands?: {
start?: string
}
}
/** Per-directory store state */
export type State = {
status: "loading" | "partial" | "complete"
agent: Agent[]
command: Command[]
project: string
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider: ProviderListResponse
config: Config
path: Path
session: Session[]
sessionTotal: number
session_status: Record<string, SessionStatus>
session_diff: Record<string, FileDiff[]>
todo: Record<string, Todo[]>
permission: Record<string, PermissionRequest[]>
question: Record<string, QuestionRequest[]>
mcp: Record<string, McpStatus>
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number
message: Record<string, Message[]>
part: Record<string, Part[]>
}
/** Global store state */
export type GlobalState = {
ready: boolean
error?: InitError
path: Path
projects: Project[]
providers: ProviderListResponse
providerAuth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
sessionTodo: Record<string, Todo[]>
}
export type InitError = {
type: "init"
message: string
}
export type DirState = {
lastAccessAt: number
}
export type EvictPlan = {
stores: string[]
state: Map<string, DirState>
pins: Set<string>
max: number
ttl: number
now: number
}
export type DisposeCheck = {
directory: string
hasStore: boolean
pinned: boolean
booting: boolean
loadingSessions: boolean
}
export type ChildOptions = {
bootstrap?: boolean
}
export const MAX_DIR_STORES = 30
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
export const SESSION_RECENT_LIMIT = 50
export const SESSION_CACHE_LIMIT = 8
export const INITIAL_STATE: State = {
project: "",
projectMeta: undefined,
icon: undefined,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
status: "loading",
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
}
export const INITIAL_GLOBAL_STATE: GlobalState = {
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
projects: [],
providers: { all: [], connected: [], default: {} },
providerAuth: {},
config: {},
reload: undefined,
sessionTodo: {},
}
+413
View File
@@ -0,0 +1,413 @@
import { useCallback, useRef, useMemo } from "react"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import { retry } from "./retry"
import { SESSION_CACHE_LIMIT } from "./types"
import { pickSessionCacheEvictions } from "./session-cache"
import {
mergeOptimisticPage,
mergeMessages,
type OptimisticItem,
} from "./optimistic"
import { useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
import { dropSessionCaches } from "./session-cache"
import { stripMessageDiffSnapshots } from "./sanitize"
import {
shouldSkipSessionPrefetch,
getSessionPrefetch,
setSessionPrefetch,
clearSessionPrefetch,
} from "./session-prefetch-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const MESSAGE_PAGE_SIZE = 200
const MAX_SEEN_DIRS = 30
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function sortParts(parts: Part[]) {
return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
}
// ---------------------------------------------------------------------------
// useSync — message loading, pagination, optimistic updates
// Message loading, pagination, optimistic updates
// ---------------------------------------------------------------------------
export function useSync() {
const sdk = useSyncSDK()
const directory = useSyncDirectory()
const store = useDirectoryStore()
const childStores = useChildStoreManager()
// Refs for mutable tracking (no re-renders)
const inflight = useRef(new Map<string, Promise<void>>())
const optimistic = useRef(new Map<string, Map<string, OptimisticItem>>())
const seen = useRef(new Map<string, Set<string>>())
const meta = useRef(new Map<string, {
limit: number
cursor: string | undefined
complete: boolean
loading: boolean
}>())
const keyFor = useCallback(
(sessionID: string) => `${directory}\n${sessionID}`,
[directory],
)
const getMetaFor = useCallback(
(sessionID: string) => {
const key = keyFor(sessionID)
return meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false }
},
[keyFor],
)
const setMetaFor = useCallback(
(sessionID: string, patch: Partial<{ limit: number; cursor: string | undefined; complete: boolean; loading: boolean }>) => {
const key = keyFor(sessionID)
const current = meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false }
meta.current.set(key, { ...current, ...patch })
},
[keyFor],
)
// Session cache eviction — two levels of LRU:
// (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT).
// Evict all cached session data for given IDs from a directory's store
const evict = useCallback(
(dir: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
const dirStore = childStores.getChild(dir)
if (!dirStore) return
const current = dirStore.getState()
const draft = {
message: { ...current.message },
part: { ...current.part },
session_status: { ...current.session_status },
session_diff: { ...current.session_diff },
todo: { ...current.todo },
permission: { ...current.permission },
question: { ...current.question },
}
dropSessionCaches(draft, sessionIDs)
dirStore.setState(draft)
// Clear meta + optimistic + prefetch cache for evicted sessions
for (const id of sessionIDs) {
optimistic.current.delete(`${dir}\n${id}`)
meta.current.delete(`${dir}\n${id}`)
}
clearSessionPrefetch(dir, sessionIDs)
},
[childStores],
)
// Get or create the seen-set for a directory. LRU reorder on access.
// When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches.
// LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS.
const seenFor = useCallback(() => {
const existing = seen.current.get(directory)
if (existing) {
// LRU reorder: delete + re-insert moves to end (most recent)
seen.current.delete(directory)
seen.current.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.current.set(directory, created)
// Evict oldest directories if over limit
while (seen.current.size > MAX_SEEN_DIRS) {
const first = seen.current.keys().next().value
if (!first) break
const staleSessionIds = [...(seen.current.get(first) ?? [])]
seen.current.delete(first)
evict(first, staleSessionIds)
}
return created
}, [directory, evict])
// Touch a session — triggers both directory-level and session-level eviction
const touch = useCallback(
(sessionID: string) => {
const s = seenFor()
const stale = pickSessionCacheEvictions({
seen: s,
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, stale)
},
[directory, seenFor, evict],
)
// Optimistic operations
const getOptimistic = useCallback(
(sessionID: string): OptimisticItem[] => {
const key = `${directory}\n${sessionID}`
return [...(optimistic.current.get(key)?.values() ?? [])]
},
[directory],
)
const setOptimistic = useCallback(
(sessionID: string, item: OptimisticItem) => {
const key = `${directory}\n${sessionID}`
const list = optimistic.current.get(key)
const sorted: OptimisticItem = { message: item.message, parts: sortParts(item.parts) }
if (list) {
list.set(item.message.id, sorted)
} else {
optimistic.current.set(key, new Map([[item.message.id, sorted]]))
}
},
[directory],
)
const clearOptimistic = useCallback(
(sessionID: string, messageID?: string) => {
const key = `${directory}\n${sessionID}`
if (!messageID) {
optimistic.current.delete(key)
return
}
const list = optimistic.current.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.current.delete(key)
},
[directory],
)
// Fetch messages from API
const fetchMessages = useCallback(
async (sessionID: string, limit: number, before?: string) => {
const result = await retry(() =>
sdk.session.messages({ sessionID, limit, before }),
)
const items = (result.data ?? []).filter((x: { info?: { id?: string } }) => !!x?.info?.id)
const session = items
.map((x: { info: Message }) => stripMessageDiffSnapshots(x.info))
.sort((a: Message, b: Message) => cmp(a.id, b.id))
const part = items.map((x: { info: { id: string }; parts: Part[] }) => ({
id: x.info.id,
part: sortParts(x.parts),
}))
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
return { session, part, cursor, complete: !cursor }
},
[sdk],
)
// Load messages for a session
const loadMessages = useCallback(
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend" }) => {
const m = getMetaFor(sessionID)
if (m.loading) return
setMetaFor(sessionID, { loading: true })
try {
const limit = m.limit
const page = await fetchMessages(sessionID, limit, options?.before)
// Merge optimistic items
const items = getOptimistic(sessionID)
const merged = mergeOptimisticPage(page, items)
for (const messageID of merged.confirmed) {
clearOptimistic(sessionID, messageID)
}
const current = store.getState()
const cached = options?.mode === "prepend" ? (current.message[sessionID] ?? []) : []
const messages = options?.mode === "prepend"
? mergeMessages(cached, merged.session)
: merged.session
// Build part updates — preserve existing references on prepend to avoid flicker
const isPrepend = options?.mode === "prepend"
let partsChanged = false
const partUpdate: Record<string, Part[]> = { ...current.part }
for (const p of merged.part) {
if (isPrepend && partUpdate[p.id]) continue // already loaded
const filtered = p.part.filter((x: Part) => !SKIP_PARTS.has(x.type))
if (filtered.length) {
partUpdate[p.id] = filtered
partsChanged = true
}
}
const patch: Record<string, unknown> = {
message: messages !== cached ? { ...current.message, [sessionID]: messages } : current.message,
}
if (!isPrepend || partsChanged) {
patch.part = partUpdate
}
store.setState(patch)
setMetaFor(sessionID, {
limit: messages.length,
cursor: merged.cursor,
complete: merged.complete,
loading: false,
})
setSessionPrefetch({
directory,
sessionID,
limit: messages.length,
cursor: merged.cursor,
complete: merged.complete,
})
} catch {
setMetaFor(sessionID, { loading: false })
}
},
[store, fetchMessages, getMetaFor, setMetaFor, getOptimistic, clearOptimistic, directory],
)
// Sync a session (load if not cached)
const syncSession = useCallback(
async (sessionID: string, force?: boolean) => {
touch(sessionID)
const key = keyFor(sessionID)
// Dedup inflight requests
const existing = inflight.current.get(key)
if (existing) return existing
const current = store.getState()
const m = getMetaFor(sessionID)
const cached = current.message[sessionID] !== undefined && m.limit > 0
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
if (cached && hasSession && !force) return
// Skip if recently fetched (TTL)
if (!force) {
const prefetchInfo = getSessionPrefetch(directory, sessionID)
if (shouldSkipSessionPrefetch({
hasMessages: cached,
info: prefetchInfo,
pageSize: MESSAGE_PAGE_SIZE,
})) return
}
const promise = (async () => {
// Fetch session info if needed
if (!hasSession || force) {
try {
const result = await retry(() => sdk.session.get({ sessionID }))
if (result.data) {
const s = store.getState()
const sessions = [...s.session]
const idx = Binary.search(sessions, sessionID, (s) => s.id)
if (idx.found) {
sessions[idx.index] = result.data
} else {
sessions.splice(idx.index, 0, result.data)
}
store.setState({ session: sessions })
}
} catch (e) {
console.error("[sync] failed to fetch session", sessionID, e)
}
}
// Load messages if needed
if (!cached || force) {
await loadMessages(sessionID)
}
})()
inflight.current.set(key, promise)
promise.finally(() => inflight.current.delete(key))
return promise
},
[store, sdk, keyFor, touch, getMetaFor, loadMessages, directory],
)
// Load more (pagination)
const loadMore = useCallback(
async (sessionID: string) => {
touch(sessionID)
const m = getMetaFor(sessionID)
if (m.loading || m.complete || !m.cursor) return
await loadMessages(sessionID, { before: m.cursor, mode: "prepend" })
},
[touch, getMetaFor, loadMessages],
)
const hasMore = useCallback(
(sessionID: string) => {
const m = getMetaFor(sessionID)
return !m.complete && !!m.cursor
},
[getMetaFor],
)
const isLoading = useCallback(
(sessionID: string) => getMetaFor(sessionID).loading,
[getMetaFor],
)
// Optimistic add (for prompt submission)
const optimisticAdd = useCallback(
(input: { sessionID: string; message: Message; parts: Part[] }) => {
setOptimistic(input.sessionID, { message: input.message, parts: input.parts })
const current = store.getState()
const message = { ...current.message }
const part = { ...current.part }
// Insert message
const messages = message[input.sessionID] ? [...message[input.sessionID]] : []
const result = Binary.search(messages, input.message.id, (m) => m.id)
if (!result.found) messages.splice(result.index, 0, input.message)
message[input.sessionID] = messages
// Insert parts
part[input.message.id] = sortParts(input.parts)
store.setState({ message, part })
},
[store, setOptimistic],
)
// Optimistic remove (for rollback on error)
const optimisticRemove = useCallback(
(input: { sessionID: string; messageID: string }) => {
clearOptimistic(input.sessionID, input.messageID)
const current = store.getState()
const message = { ...current.message }
const part = { ...current.part }
const messages = message[input.sessionID]
if (messages) {
const next = [...messages]
const result = Binary.search(next, input.messageID, (m) => m.id)
if (result.found) {
next.splice(result.index, 1)
message[input.sessionID] = next
}
}
delete part[input.messageID]
store.setState({ message, part })
},
[store, clearOptimistic],
)
return useMemo(
() => ({
syncSession,
loadMore,
hasMore,
isLoading,
optimistic: {
add: optimisticAdd,
remove: optimisticRemove,
},
}),
[syncSession, loadMore, hasMore, isLoading, optimisticAdd, optimisticRemove],
)
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Viewport Store per-session scroll anchors, streaming state, memory.
* Extracted from session-ui-store for subscription isolation.
*/
import { create } from "zustand"
export type SessionMemoryState = {
viewportAnchor: number
isStreaming: boolean
streamStartTime?: number
lastAccessedAt: number
backgroundMessageCount: number
loadedTurnCount?: number
hasMoreAbove?: boolean
hasMoreTurnsAbove?: boolean
historyLoading?: boolean
historyComplete?: boolean
historyLimit?: number
totalAvailableMessages?: number
streamingCooldownUntil?: number
isZombie?: boolean
lastUserMessageAt?: number
}
export type ViewportState = {
sessionMemoryState: Map<string, SessionMemoryState>
isSyncing: boolean
updateViewportAnchor: (sessionId: string, anchor: number) => void
}
export const useViewportStore = create<ViewportState>()((set) => ({
sessionMemoryState: new Map(),
isSyncing: false,
updateViewportAnchor: (sessionId, anchor) =>
set((s) => {
const map = new Map(s.sessionMemoryState)
const existing = map.get(sessionId) ?? {
viewportAnchor: 0,
isStreaming: false,
lastAccessedAt: Date.now(),
backgroundMessageCount: 0,
}
map.set(sessionId, { ...existing, viewportAnchor: anchor, lastAccessedAt: Date.now() })
return { sessionMemoryState: map }
}),
}))
+23
View File
@@ -0,0 +1,23 @@
/**
* Voice Store voice connection and activity state.
* Extracted from session-ui-store for subscription isolation.
*/
import { create } from "zustand"
export type VoiceStatus = "disconnected" | "connecting" | "connected" | "error"
export type VoiceMode = "idle" | "speaking" | "listening"
export type VoiceState = {
voiceStatus: VoiceStatus
voiceMode: VoiceMode
setVoiceStatus: (status: VoiceStatus) => void
setVoiceMode: (mode: VoiceMode) => void
}
export const useVoiceStore = create<VoiceState>()((set) => ({
voiceStatus: "disconnected",
voiceMode: "idle",
setVoiceStatus: (status) => set({ voiceStatus: status }),
setVoiceMode: (mode) => set({ voiceMode: mode }),
}))