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:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
8dfe833faf
commit
c9e31a0e6c
@@ -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: "" }))
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" })
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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: [] }),
|
||||
}))
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}))
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[] = []
|
||||
@@ -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] ?? []
|
||||
}
|
||||
@@ -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: {},
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}),
|
||||
}))
|
||||
@@ -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 }),
|
||||
}))
|
||||
Reference in New Issue
Block a user