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

* fix: added desktop app background throttling

* perf: add streaming debug metrics panel

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

* perf: batch streaming updates more aggressively

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

* perf: split streaming event handling and coalesce deltas

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

* perf: isolate streaming rows from chat rerenders

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

* perf: streamline chat streaming and SSE proxying

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

* fix: preserve the first streaming text chunk

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

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

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

* fix: stabilize chat rendering and disable timeline interactions

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

* perf: track static message rerenders during streaming

* perf: reduce sorted-mode activity rerender fanout

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

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

* fix: keep sorted activity mounted while stream grows

* fix: stabilize session and history scroll rendering

* refactor: decouple server routes from index

* refactor: extract fs module from server index

* refactor: move opencode route ownership into module

* refactor: extract notification route registration

* refactor: extract opencode and notification runtimes from index

* refactor: extract settings runtime and complete server modularization pass

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

* refactor: extract server modules from monolithic index.js

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

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

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

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

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

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

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

* feat: notification store, session actions, activity detection

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: null safety for sync state slices

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

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

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

* fix: header session lookup across all child stores

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

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

* docs: add sync event handling guide

* Optimize session prefetch and improve delete/archive UX

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

* Add file content cache and sync optimizations

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

* Improve session sidebar error handling and add diff prefetch filtering

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

* Replace sendMessage with optimisticSend wrapper

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

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

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

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

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

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

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

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

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

* perf: optimize startup git status polling and diff rendering

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

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

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

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

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

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

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

* refactor: decouple web server index orchestration runtimes

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

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

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

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

* fix: restore session model selection consistently on session switch

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

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

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

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

* feat: add reusable fuzzy branch search for worktrees

* chore: drop planning docs from feature branch

* feat: make worktree branch refresh manual

* feat: add configurable session retention action

* refactor: centralize global session state in ui store

* fix: cancel debounced permission push after reply

* docs: clarify global and directory session store architecture

* docs: refine agent development rules and session activity guidance

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

* chore: updated .gitignore

---------

Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
Bohdan Triapitsyn
2026-03-31 18:47:00 +03:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 8dfe833faf
commit c9e31a0e6c
245 changed files with 31986 additions and 32683 deletions
-167
View File
@@ -47,8 +47,6 @@ interface ContextActions {
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void;
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined;
analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: any[], messages: Map<string, { info: any; parts: any[] }[]>) => Promise<Map<string, { providerId: string; modelId: string; timestamp: number }>>;
getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => ContextUsage | null;
@@ -201,171 +199,6 @@ export const useContextStore = create<ContextStore>()(
return modelMap.get(`${providerId}/${modelId}`);
},
analyzeAndSaveExternalSessionChoices: async (sessionId: string, agents: any[], messages: Map<string, { info: any; parts: any[] }[]>) => {
const { saveAgentModelForSession, saveAgentModelVariantForSession } = get();
const agentLastChoices = new Map<
string,
{
providerId: string;
modelId: string;
timestamp: number;
}
>();
const extractAgentFromMessage = (messageInfo: any, messageIndex: number): string | null => {
if ("mode" in messageInfo && messageInfo.mode && typeof messageInfo.mode === "string") {
const modeAgent = agents.find((a) => a.name === messageInfo.mode);
if (modeAgent) {
return messageInfo.mode;
}
}
if ("agent" in messageInfo && messageInfo.agent && typeof messageInfo.agent === "string") {
const agent = agents.find((a) => a.name === messageInfo.agent);
if (agent) {
return messageInfo.agent;
}
}
if (messageInfo.providerID && messageInfo.modelID) {
const matchingAgent = agents.find((agent) => agent.model?.providerID === messageInfo.providerID && agent.model?.modelID === messageInfo.modelID);
if (matchingAgent) {
return matchingAgent.name;
}
}
const { currentAgentContext } = get();
const contextAgent = currentAgentContext.get(sessionId);
if (contextAgent && agents.find((a) => a.name === contextAgent)) {
return contextAgent;
}
if (messageIndex > 0 && messageInfo.providerID && messageInfo.modelID) {
const sessionMessages = messages.get(sessionId) || [];
const assistantMessages = sessionMessages.filter((m) => m.info.role === "assistant").sort((a, b) => a.info.time.created - b.info.time.created);
for (let i = messageIndex - 1; i >= 0; i--) {
const prevMessage = assistantMessages[i];
const prevInfo = prevMessage.info as any;
if (prevInfo.providerID === messageInfo.providerID && prevInfo.modelID === messageInfo.modelID) {
if (prevInfo.mode && typeof prevInfo.mode === "string") {
const prevModeAgent = agents.find((a) => a.name === prevInfo.mode);
if (prevModeAgent) {
return prevInfo.mode;
}
}
const prevMatchingAgent = agents.find((agent) => agent.model?.providerID === prevInfo.providerID && agent.model?.modelID === prevInfo.modelID);
if (prevMatchingAgent) {
return prevMatchingAgent.name;
}
}
}
}
if (messageInfo.providerID && messageInfo.modelID) {
const buildAgent = agents.find((a) => a.name === "build");
if (buildAgent) {
return "build";
}
}
return null;
};
const sessionMessages = messages.get(sessionId) || [];
const allMessages = sessionMessages.filter((m: any) => m.info.role === "assistant" || m.info.role === "user").sort((a: any, b: any) => a.info.time.created - b.info.time.created);
const assistantMessages = sessionMessages.filter((m: any) => m.info.role === "assistant").sort((a: any, b: any) => a.info.time.created - b.info.time.created);
// Track variant from user messages to apply to corresponding assistant response
let pendingVariant: string | undefined = undefined;
let pendingUserModel: { providerID: string; modelID: string } | undefined = undefined;
for (let messageIndex = 0; messageIndex < allMessages.length; messageIndex++) {
const message = allMessages[messageIndex];
const { info } = message;
const infoAny = info as any;
// User messages have variant and model info in different structure
if (infoAny.role === "user") {
const agentName = typeof infoAny.mode === 'string' && infoAny.mode.trim().length > 0
? infoAny.mode
: (typeof infoAny.agent === 'string' && infoAny.agent.trim().length > 0 ? infoAny.agent : undefined);
const userProvider = typeof infoAny.model?.providerID === 'string' && infoAny.model.providerID.trim().length > 0
? infoAny.model.providerID
: (typeof infoAny.providerID === 'string' && infoAny.providerID.trim().length > 0 ? infoAny.providerID : undefined);
const userModel = typeof infoAny.model?.modelID === 'string' && infoAny.model.modelID.trim().length > 0
? infoAny.model.modelID
: (typeof infoAny.modelID === 'string' && infoAny.modelID.trim().length > 0 ? infoAny.modelID : undefined);
const userVariant = typeof infoAny.variant === 'string' && infoAny.variant.trim().length > 0
? infoAny.variant
: undefined;
if (agentName && userProvider && userModel && agents.find((a) => a.name === agentName)) {
const choice = {
providerId: userProvider,
modelId: userModel,
timestamp: info.time.created,
};
const existing = agentLastChoices.get(agentName);
if (!existing || choice.timestamp > existing.timestamp) {
agentLastChoices.set(agentName, choice);
}
saveAgentModelVariantForSession(sessionId, agentName, userProvider, userModel, userVariant);
}
// User message: variant is top-level, model is nested in model.providerID/modelID
pendingVariant = userVariant;
pendingUserModel = infoAny.model?.providerID && infoAny.model?.modelID
? { providerID: infoAny.model.providerID, modelID: infoAny.model.modelID }
: undefined;
continue;
}
// Assistant message: providerID/modelID are top-level
if (infoAny.providerID && infoAny.modelID) {
const agentName = extractAgentFromMessage(infoAny, assistantMessages.indexOf(message));
if (agentName && agents.find((a) => a.name === agentName)) {
// Apply pending variant from user message if model matches
if (pendingUserModel &&
pendingUserModel.providerID === infoAny.providerID &&
pendingUserModel.modelID === infoAny.modelID) {
saveAgentModelVariantForSession(sessionId, agentName, infoAny.providerID, infoAny.modelID, pendingVariant);
}
const choice = {
providerId: infoAny.providerID,
modelId: infoAny.modelID,
timestamp: info.time.created,
};
const existing = agentLastChoices.get(agentName);
if (!existing || choice.timestamp > existing.timestamp) {
agentLastChoices.set(agentName, choice);
}
}
}
// Clear pending variant after processing assistant message
pendingVariant = undefined;
pendingUserModel = undefined;
}
for (const [agentName, choice] of agentLastChoices) {
saveAgentModelForSession(sessionId, agentName, choice.providerId, choice.modelId);
}
return agentLastChoices;
},
getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => {
if (!sessionId) return null;
+9 -5
View File
@@ -1,4 +1,5 @@
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
import { retry } from "@/sync/retry";
export type GlobalSessionRecord = Session & {
project?: {
@@ -71,11 +72,14 @@ export async function listGlobalSessionPages(
let cursor: number | undefined;
while (true) {
const response = await apiClient.experimental.session.list({
archived: options.archived,
limit: options.pageSize,
...(cursor ? { cursor } : {}),
});
const response = await retry(
() => apiClient.experimental.session.list({
archived: options.archived,
limit: options.pageSize,
...(cursor ? { cursor } : {}),
}),
{ attempts: 3, delay: 500, retryIf: () => true },
);
const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : [];
if (payload.length === 0) {
@@ -9,6 +9,13 @@ export interface QueuedMessage {
content: string;
attachments?: AttachedFile[];
createdAt: number;
/** Send config captured at queue time — used as-is when auto-sending */
sendConfig?: {
providerID: string;
modelID: string;
agent?: string;
variant?: string;
};
}
interface MessageQueueState {
@@ -42,6 +49,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
content: message.content,
attachments: message.attachments,
createdAt: Date.now(),
sendConfig: message.sendConfig,
};
set((state) => {
File diff suppressed because it is too large Load Diff
+60 -195
View File
@@ -1,65 +1,28 @@
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import type { Session } from "@opencode-ai/sdk/v2/client";
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
import {
autoRespondsPermission,
normalizeDirectory,
sessionAcceptKey,
type PermissionAutoAcceptMap,
} from "./utils/permissionAutoAccept";
import { getSafeStorage } from "./utils/safeStorage";
import { useMessageStore } from "./messageStore";
import { useSessionStore } from "./sessionStore";
import { getAllSyncSessions } from "@/sync/sync-refs";
import { opencodeClient } from "@/lib/opencode/client";
import { useSessionUIStore } from "@/sync/session-ui-store";
interface PermissionState {
permissions: Map<string, PermissionRequest[]>;
autoAccept: PermissionAutoAcceptMap;
}
interface PermissionActions {
addPermission: (permission: PermissionRequest) => void;
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
dismissPermission: (sessionId: string, requestId: string) => void;
isSessionAutoAccepting: (sessionId: string) => boolean;
setSessionAutoAccept: (sessionId: string, enabled: boolean) => Promise<void>;
}
type PermissionStore = PermissionState & PermissionActions;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const sanitizePermissionEntries = (value: unknown): Array<[string, PermissionRequest[]]> => {
if (!Array.isArray(value)) {
return [];
}
const entries: Array<[string, PermissionRequest[]]> = [];
value.forEach((entry) => {
if (!Array.isArray(entry) || entry.length !== 2) {
return;
}
const [sessionId, permissions] = entry;
if (typeof sessionId !== "string" || !Array.isArray(permissions)) {
return;
}
entries.push([sessionId, permissions as PermissionRequest[]]);
});
return entries;
};
const executeWithPermissionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
try {
const sessionStore = useSessionStore.getState();
const directory = sessionStore.getDirectoryForSession(sessionId);
if (directory) {
return opencodeClient.withDirectory(directory, operation);
}
} catch (error) {
console.warn('Failed to resolve session directory for permission handling:', error);
}
return operation();
};
const resolveLineage = (sessionID: string, sessions: Session[]): string[] => {
const map = new Map<string, Session>();
for (const session of sessions) {
@@ -82,148 +45,40 @@ const autoRespondsPermissionBySession = (
sessions: Session[],
sessionID: string,
): boolean => {
for (const id of resolveLineage(sessionID, sessions)) {
if (id in autoAccept) {
return autoAccept[id] === true;
const targetSession = sessions.find((session) => session.id === sessionID);
const mappedDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionID);
const directory = normalizeDirectory(mappedDirectory ?? (targetSession as Session & { directory?: string | null })?.directory ?? null);
if (!directory) {
for (const id of resolveLineage(sessionID, sessions)) {
if (id in autoAccept) {
return autoAccept[id] === true;
}
}
}
return false;
};
const shouldAutoRespond = (permission: PermissionRequest, autoAccept: PermissionAutoAcceptMap): boolean => {
if (!permission?.sessionID) {
return false;
}
const sessionStore = useSessionStore.getState();
const sessions = sessionStore.sessions;
return autoRespondsPermissionBySession(
return autoRespondsPermission({
autoAccept,
sessions,
permission.sessionID,
);
sessionID,
directory,
});
};
const collectPermissionDirectories = (fallbackDirectory?: string | null): string[] => {
const sessionStore = useSessionStore.getState();
const dirs = new Set<string>();
const fallback = normalizeDirectory(fallbackDirectory);
if (fallback) {
dirs.add(fallback);
}
const currentDirectory = normalizeDirectory(opencodeClient.getDirectory());
if (currentDirectory) {
dirs.add(currentDirectory);
}
for (const session of sessionStore.sessions) {
const normalized = normalizeDirectory((session as { directory?: string | null }).directory);
if (normalized) {
dirs.add(normalized);
}
}
return Array.from(dirs);
};
const reconcilePendingAutoAccept = async (
autoAccept: PermissionAutoAcceptMap,
fallbackDirectory?: string | null,
) => {
const directories = collectPermissionDirectories(fallbackDirectory);
if (directories.length === 0) {
return;
}
const pending = await opencodeClient.listPendingPermissions({ directories });
if (pending.length === 0) {
return;
}
for (const request of pending) {
if (!request?.sessionID || !request?.id) {
continue;
}
if (!shouldAutoRespond(request, autoAccept)) {
continue;
}
try {
await executeWithPermissionDirectory(request.sessionID, () => opencodeClient.replyToPermission(request.id, 'once'));
} catch {
// ignored
}
}
};
const getStorage = () => createJSONStorage(() => getSafeStorage());
export const usePermissionStore = create<PermissionStore>()(
devtools(
persist(
(set, get) => ({
permissions: new Map(),
autoAccept: {},
addPermission: (permission: PermissionRequest) => {
const sessionId = permission.sessionID;
if (!sessionId) {
return;
}
const existing = get().permissions.get(sessionId);
if (existing?.some((entry) => entry.id === permission.id)) {
return;
}
if (shouldAutoRespond(permission, get().autoAccept)) {
get().respondToPermission(sessionId, permission.id, 'once').catch(() => {
});
return;
}
set((state) => {
const sessionPermissions = state.permissions.get(sessionId) || [];
const newPermissions = new Map(state.permissions);
newPermissions.set(sessionId, [...sessionPermissions, permission]);
return { permissions: newPermissions };
});
},
respondToPermission: async (sessionId: string, requestId: string, response: PermissionResponse) => {
await executeWithPermissionDirectory(sessionId, () => opencodeClient.replyToPermission(requestId, response));
if (response === 'reject') {
const messageStore = useMessageStore.getState();
await messageStore.abortCurrentOperation(sessionId);
}
set((state) => {
const sessionPermissions = state.permissions.get(sessionId) || [];
const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId);
const newPermissions = new Map(state.permissions);
newPermissions.set(sessionId, updatedPermissions);
return { permissions: newPermissions };
});
},
dismissPermission: (sessionId: string, requestId: string) => {
set((state) => {
const sessionPermissions = state.permissions.get(sessionId) || [];
const updatedPermissions = sessionPermissions.filter((p) => p.id !== requestId);
const newPermissions = new Map(state.permissions);
newPermissions.set(sessionId, updatedPermissions);
return { permissions: newPermissions };
});
},
isSessionAutoAccepting: (sessionId: string) => {
if (!sessionId) {
return false;
}
const sessions = useSessionStore.getState().sessions;
const sessions = getAllSyncSessions();
return autoRespondsPermissionBySession(get().autoAccept, sessions, sessionId);
},
@@ -232,49 +87,59 @@ export const usePermissionStore = create<PermissionStore>()(
return;
}
set((state) => ({
autoAccept: {
...state.autoAccept,
[sessionId]: enabled,
},
}));
const sessions = getAllSyncSessions();
const targetSession = sessions.find((session) => session.id === sessionId);
const mappedDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId);
const directory = normalizeDirectory(mappedDirectory ?? (targetSession as Session & { directory?: string | null })?.directory ?? null);
const key = directory ? sessionAcceptKey(sessionId, directory) : sessionId;
if (!enabled) {
set((state) => {
const autoAccept = { ...state.autoAccept };
if (directory) {
delete autoAccept[sessionId];
}
autoAccept[key] = enabled;
return { autoAccept };
});
if (!enabled || !directory) {
return;
}
const sessionDirectory = useSessionStore.getState().getDirectoryForSession(sessionId);
void reconcilePendingAutoAccept(get().autoAccept, sessionDirectory);
const pending = await opencodeClient.listPendingPermissions({ directories: [directory] });
const client = opencodeClient.getScopedSdkClient(directory);
const sessionLineage = new Set(resolveLineage(sessionId, sessions));
await Promise.all(
pending
.filter((permission) => sessionLineage.has(permission.sessionID))
.map((permission) => client.permission.reply({ requestID: permission.id, reply: "once" }).catch(() => undefined)),
);
},
}),
{
name: "permission-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
permissions: Array.from(state.permissions.entries()),
autoAccept: state.autoAccept,
}),
storage: getStorage(),
partialize: (state) => ({ autoAccept: state.autoAccept }),
merge: (persistedState, currentState) => {
if (!isRecord(persistedState)) {
return currentState;
}
const entries = sanitizePermissionEntries(persistedState.permissions);
const autoAccept = isRecord(persistedState.autoAccept)
? Object.fromEntries(
Object.entries(persistedState.autoAccept).filter((entry): entry is [string, boolean] => {
return typeof entry[0] === "string" && typeof entry[1] === "boolean";
}),
)
: {};
return {
const merged = {
...currentState,
permissions: new Map(entries),
autoAccept,
...(persistedState as Partial<PermissionStore>),
};
const nextAutoAccept = Object.fromEntries(
Object.entries(merged.autoAccept || {}).map(([sessionId, enabled]) => [
sessionId,
Boolean(enabled),
]),
);
return {
...merged,
autoAccept: nextAutoAccept,
};
},
}
),
{
name: "permission-store",
}
{ name: "permission-store" }
)
);
-123
View File
@@ -1,123 +0,0 @@
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import type { QuestionRequest } from "@/types/question";
import { getSafeStorage } from "./utils/safeStorage";
import { useSessionStore } from "./sessionStore";
interface QuestionState {
questions: Map<string, QuestionRequest[]>;
}
interface QuestionActions {
addQuestion: (question: QuestionRequest) => void;
dismissQuestion: (sessionId: string, requestId: string) => void;
respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => Promise<void>;
rejectQuestion: (sessionId: string, requestId: string) => Promise<void>;
}
type QuestionStore = QuestionState & QuestionActions;
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
const sanitizeQuestionEntries = (value: unknown): Array<[string, QuestionRequest[]]> => {
if (!Array.isArray(value)) {
return [];
}
const entries: Array<[string, QuestionRequest[]]> = [];
value.forEach((entry) => {
if (!Array.isArray(entry) || entry.length !== 2) {
return;
}
const [sessionId, questions] = entry;
if (typeof sessionId !== "string" || !Array.isArray(questions)) {
return;
}
entries.push([sessionId, questions as QuestionRequest[]]);
});
return entries;
};
const executeWithQuestionDirectory = async <T>(sessionId: string, operation: () => Promise<T>): Promise<T> => {
try {
const sessionStore = useSessionStore.getState();
const directory = sessionStore.getDirectoryForSession(sessionId);
if (directory) {
return opencodeClient.withDirectory(directory, operation);
}
} catch (error) {
console.warn("Failed to resolve session directory for question handling:", error);
}
return operation();
};
export const useQuestionStore = create<QuestionStore>()(
devtools(
persist(
(set, get) => ({
questions: new Map(),
addQuestion: (question: QuestionRequest) => {
const sessionId = question.sessionID;
if (!sessionId) {
return;
}
const existing = get().questions.get(sessionId);
if (existing?.some((entry) => entry.id === question.id)) {
return;
}
set((state) => {
const sessionQuestions = state.questions.get(sessionId) || [];
const next = new Map(state.questions);
next.set(sessionId, [...sessionQuestions, question]);
return { questions: next };
});
},
dismissQuestion: (sessionId: string, requestId: string) => {
if (!sessionId || !requestId) {
return;
}
set((state) => {
const sessionQuestions = state.questions.get(sessionId) || [];
const updated = sessionQuestions.filter((q) => q.id !== requestId);
const next = new Map(state.questions);
next.set(sessionId, updated);
return { questions: next };
});
},
respondToQuestion: async (sessionId: string, requestId: string, answers: string[] | string[][]) => {
await executeWithQuestionDirectory(sessionId, () => opencodeClient.replyToQuestion(requestId, answers));
get().dismissQuestion(sessionId, requestId);
},
rejectQuestion: async (sessionId: string, requestId: string) => {
await executeWithQuestionDirectory(sessionId, () => opencodeClient.rejectQuestion(requestId));
get().dismissQuestion(sessionId, requestId);
},
}),
{
name: "question-store",
storage: createJSONStorage(() => getSafeStorage()),
partialize: (state) => ({
questions: Array.from(state.questions.entries()),
}),
merge: (persistedState, currentState) => {
if (!isRecord(persistedState)) {
return currentState;
}
const entries = sanitizeQuestionEntries(persistedState.questions);
return {
...currentState,
questions: new Map(entries),
};
},
}
),
{ name: "question-store" }
)
);
File diff suppressed because it is too large Load Diff
+2 -12
View File
@@ -187,15 +187,7 @@ export interface SessionStore {
{ type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number; confirmedAt?: number }
>;
// Server-authoritative session attention state
// Tracks which sessions need user attention based on server-side logic
sessionAttentionStates: Map<string, {
needsAttention: boolean;
lastUserMessageAt: number | null;
lastStatusChangeAt: number;
status: 'idle' | 'busy' | 'retry';
isViewed: boolean;
}>;
// sessionAttentionStates removed — replaced by notification-store
userSummaryTitles: Map<string, { title: string; createdAt: number | null }>;
@@ -263,7 +255,7 @@ export interface SessionStore {
clearError: () => void;
getSessionsByDirectory: (directory: string) => Session[];
getDirectoryForSession: (sessionId: string) => string | null;
getLastMessageModel: (sessionId: string) => { providerID?: string; modelID?: string } | null;
getLastUserChoice: (sessionId: string) => { agent?: string; providerID?: string; modelID?: string; variant?: string } | null;
getCurrentAgent: (sessionId: string) => string | undefined;
syncMessages: (
sessionId: string,
@@ -291,8 +283,6 @@ export interface SessionStore {
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void;
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined;
analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => Promise<Map<string, { providerId: string; modelId: string; timestamp: number }>>;
isOpenChamberCreatedSession: (sessionId: string) => boolean;
File diff suppressed because it is too large Load Diff
+43 -36
View File
@@ -6,8 +6,9 @@ import { opencodeClient } from "@/lib/opencode/client";
import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import type { ModelMetadata } from "@/types";
import { getSafeStorage } from "./utils/safeStorage";
import type { SessionStore } from "./types/sessionTypes";
import { filterVisibleAgents } from "./useAgentsStore";
import { useSessionUIStore } from "@/sync/session-ui-store";
import { useSelectionStore } from "@/sync/selection-store";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { updateDesktopSettings } from "@/lib/persistence";
import { useDirectoryStore } from "@/stores/useDirectoryStore";
@@ -529,10 +530,13 @@ interface ConfigStore {
declare global {
interface Window {
__zustand_config_store__?: UseBoundStore<StoreApi<ConfigStore>>;
__zustand_session_store__?: UseBoundStore<StoreApi<SessionStore>>;
}
}
// In-flight dedup: prevent concurrent duplicate loadProviders/loadAgents calls for the same directory
const _inFlightProviders = new Map<string, Promise<void>>();
const _inFlightAgents = new Map<string, Promise<boolean>>();
export const useConfigStore = create<ConfigStore>()(
devtools(
persist(
@@ -724,6 +728,12 @@ export const useConfigStore = create<ConfigStore>()(
loadProviders: async (options) => {
const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey));
// Dedup: if a load is already in-flight for this directory, reuse it
const existing = _inFlightProviders.get(directoryKey);
if (existing) return existing;
const promise = (async () => {
const existingSnapshot = get().directoryScoped[directoryKey];
const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []);
const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {});
@@ -872,6 +882,10 @@ export const useConfigStore = create<ConfigStore>()(
return nextState;
});
})().finally(() => _inFlightProviders.delete(directoryKey));
_inFlightProviders.set(directoryKey, promise);
return promise;
},
setProvider: (providerId: string) => {
@@ -1082,6 +1096,12 @@ export const useConfigStore = create<ConfigStore>()(
loadAgents: async (options) => {
const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey));
// Dedup: if a load is already in-flight for this directory, reuse it
const existing = _inFlightAgents.get(directoryKey);
if (existing) return existing;
const promise = (async (): Promise<boolean> => {
const existingSnapshot = get().directoryScoped[directoryKey];
const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []);
let lastError: unknown = null;
@@ -1392,6 +1412,10 @@ export const useConfigStore = create<ConfigStore>()(
});
return false;
})().finally(() => _inFlightAgents.delete(directoryKey));
_inFlightAgents.set(directoryKey, promise);
return promise;
},
setAgent: (agentName: string | undefined) => {
@@ -1424,44 +1448,29 @@ export const useConfigStore = create<ConfigStore>()(
};
});
if (agentName && typeof window !== "undefined") {
if (agentName) {
const { currentSessionId } = useSessionUIStore.getState();
const selState = useSelectionStore.getState();
const sessionStore = window.__zustand_session_store__;
if (sessionStore) {
const sessionState = sessionStore.getState();
const { currentSessionId, isOpenChamberCreatedSession, initializeNewOpenChamberSession, getAgentModelForSession } = sessionState;
if (currentSessionId) {
selState.saveSessionAgentSelection(currentSessionId, agentName);
}
if (currentSessionId) {
sessionStore.setState((state) => {
const newAgentContext = new Map(state.currentAgentContext);
newAgentContext.set(currentSessionId, agentName);
return { currentAgentContext: newAgentContext };
});
}
if (currentSessionId && isOpenChamberCreatedSession(currentSessionId)) {
const existingAgentModel = getAgentModelForSession(currentSessionId, agentName);
if (!existingAgentModel) {
initializeNewOpenChamberSession(currentSessionId, agents);
}
if (currentSessionId && useSessionUIStore.getState().isOpenChamberCreatedSession(currentSessionId)) {
const existingAgentModel = selState.getAgentModelForSession(currentSessionId, agentName);
if (!existingAgentModel) {
useSessionUIStore.getState().initializeNewOpenChamberSession(currentSessionId, agents);
}
}
}
if (agentName && typeof window !== "undefined") {
const sessionStore = window.__zustand_session_store__;
if (sessionStore?.getState) {
const { currentSessionId, getAgentModelForSession } = sessionStore.getState();
if (agentName) {
const { currentSessionId } = useSessionUIStore.getState();
if (currentSessionId) {
const existingAgentModel = getAgentModelForSession(currentSessionId, agentName);
if (existingAgentModel) {
return;
}
if (currentSessionId) {
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
if (existingAgentModel) {
return;
}
}
@@ -1792,9 +1801,7 @@ export const useConfigStore = create<ConfigStore>()(
return undefined;
}
const derived = deriveModelMetadata(providerId, model);
set({ modelsMetadata: new Map(modelsMetadata).set(key, derived) });
return derived;
return deriveModelMetadata(providerId, model);
},
getVisibleAgents: () => {
const { agents } = get();
+22 -13
View File
@@ -33,6 +33,9 @@ const fetchStatus = async (
return payload;
};
// In-flight dedup for refreshStatus
let _inFlightAuthRefresh: Promise<GitHubAuthStatusWithError | null> | null = null;
export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
status: null,
isLoading: false,
@@ -44,19 +47,25 @@ export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
return status;
}
if (_inFlightAuthRefresh) return _inFlightAuthRefresh;
set({ isLoading: true });
try {
const payload = await fetchStatus(runtimeGitHub);
set({ status: payload, isLoading: false, hasChecked: true });
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set({
status: { connected: false, error: message },
isLoading: false,
hasChecked: true,
});
return null;
}
_inFlightAuthRefresh = (async () => {
try {
const payload = await fetchStatus(runtimeGitHub);
set({ status: payload, isLoading: false, hasChecked: true });
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set({
status: { connected: false, error: message },
isLoading: false,
hasChecked: true,
});
return null;
}
})().finally(() => { _inFlightAuthRefresh = null; });
return _inFlightAuthRefresh;
},
}));
+44 -23
View File
@@ -16,8 +16,9 @@ const LOG_STALE_THRESHOLD = 10000;
const REPO_CHECK_STALE_THRESHOLD = 60_000;
const DIFF_PREFETCH_MAX_FILES = 25;
const DIFF_PREFETCH_FOCUS_MAX_FILES = 40;
const DIFF_PREFETCH_CONCURRENCY = 4;
const DIFF_PREFETCH_CONCURRENCY = 2;
const DIFF_PREFETCH_TIMEOUT_MS = 15000;
const DIFF_PREFETCH_LARGE_FILE_THRESHOLD = 500; // skip prefetch for files with >500 changed lines
const RECENT_DIRECTORIES_LIMIT = 3;
// Diff cache limits to prevent memory bloat with many modified files
@@ -57,7 +58,7 @@ interface GitStore {
setActiveDirectory: (directory: string | null) => void;
getDirectoryState: (directory: string) => DirectoryGitState | null;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean }) => Promise<boolean>;
fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise<boolean>;
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
@@ -87,7 +88,7 @@ interface GitFileDiffResponse {
interface GitAPI {
checkIsGitRepository: (directory: string) => Promise<boolean>;
getGitStatus: (directory: string) => Promise<GitStatus>;
getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise<GitStatus>;
getGitBranches: (directory: string) => Promise<GitBranch>;
getGitLog: (directory: string, options?: { maxCount?: number }) => Promise<GitLogResponse>;
getCurrentGitIdentity: (directory: string) => Promise<GitIdentitySummary | null>;
@@ -216,7 +217,8 @@ const hasStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | nu
}
}
if (haveDiffStatsChanged(oldStatus.diffStats, newStatus.diffStats)) return true;
// Skip diffStats comparison when light mode omits them (undefined)
if (newStatus.diffStats !== undefined && haveDiffStatsChanged(oldStatus.diffStats, newStatus.diffStats)) return true;
return false;
};
@@ -249,21 +251,24 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus |
}
}
const oldStats = oldStatus?.diffStats ?? {};
const newStats = newStatus.diffStats ?? {};
const allStatPaths = new Set<string>([...Object.keys(oldStats), ...Object.keys(newStats)]);
// Only compare diffStats when light mode provides them (non-undefined)
if (newStatus.diffStats !== undefined) {
const oldStats = oldStatus?.diffStats ?? {};
const newStats = newStatus.diffStats ?? {};
const allStatPaths = new Set<string>([...Object.keys(oldStats), ...Object.keys(newStats)]);
for (const filePath of allStatPaths) {
const oldEntry = oldStats[filePath];
const newEntry = newStats[filePath];
for (const filePath of allStatPaths) {
const oldEntry = oldStats[filePath];
const newEntry = newStats[filePath];
if (!oldEntry || !newEntry) {
changed.add(filePath);
continue;
}
if (!oldEntry || !newEntry) {
changed.add(filePath);
continue;
}
if (oldEntry.insertions !== newEntry.insertions || oldEntry.deletions !== newEntry.deletions) {
changed.add(filePath);
if (oldEntry.insertions !== newEntry.insertions || oldEntry.deletions !== newEntry.deletions) {
changed.add(filePath);
}
}
}
@@ -371,7 +376,7 @@ export const useGitStore = create<GitStore>()(
return false;
}
const newStatus = await git.getGitStatus(directory);
const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined);
if (hasStatusChanged(dirState.status, newStatus)) {
statusChanged = true;
@@ -402,10 +407,15 @@ export const useGitStore = create<GitStore>()(
bumpDiffFetchGeneration(directory);
}
// Preserve diffStats from previous status when light mode returns none
const mergedStatus = newStatus.diffStats === undefined && currentDirState.status?.diffStats
? { ...newStatus, diffStats: currentDirState.status.diffStats }
: newStatus;
newDirectories.set(directory, {
...currentDirState,
isGitRepo: true,
status: newStatus,
status: mergedStatus,
diffCache: nextDiffCache,
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
lastStatusFetch: Date.now(),
@@ -534,8 +544,7 @@ export const useGitStore = create<GitStore>()(
await get().fetchIdentity(directory, git);
// Pre-fetch all diffs so they're ready when user opens Diff tab
void get().fetchAllDiffs(directory, git);
// Diff prefetch deferred — triggered on-demand when Git tab opens (GitView reactive prefetch)
},
@@ -581,6 +590,7 @@ export const useGitStore = create<GitStore>()(
const { maxFiles = DIFF_PREFETCH_FOCUS_MAX_FILES } = options;
const availablePaths = new Set(dirState.status.files.map((file) => file.path));
const diffStats = dirState.status.diffStats;
const inFlight = getInFlightDiffs(directory);
const dedupedPaths: string[] = [];
@@ -599,6 +609,11 @@ export const useGitStore = create<GitStore>()(
if (inFlight.has(filePath)) {
continue;
}
// Skip large files during prefetch — they'll be fetched on-demand when user clicks
const stats = diffStats?.[filePath];
if (stats && (stats.insertions + stats.deletions) > DIFF_PREFETCH_LARGE_FILE_THRESHOLD) {
continue;
}
dedupedPaths.push(filePath);
}
@@ -731,18 +746,24 @@ export const useGitStore = create<GitStore>()(
let anyStatusChanged = false;
const heavyFollowUps: string[] = [];
for (const targetDirectory of pollTargets) {
const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true });
const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true, mode: 'light' });
if (statusChanged) {
anyStatusChanged = true;
heavyFollowUps.push(targetDirectory);
if (targetDirectory === activeDirectory) {
await get().fetchLog(activeDirectory, git);
// Pre-fetch all diffs so they're ready when user opens Diff tab
void get().fetchAllDiffs(activeDirectory, git);
// Diff prefetch deferred — triggered on-demand when Git tab opens (GitView reactive prefetch)
}
}
}
// Light mode detected real changes — follow up with heavy fetch for diffStats
for (const dir of heavyFollowUps) {
get().fetchStatus(dir, git, { silent: true });
}
const bounds = getPollingBounds(get().pollingMode);
if (anyStatusChanged) {
// Reset to base interval on changes
@@ -0,0 +1,308 @@
import { create } from 'zustand';
import type { Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { listGlobalSessionPages } from '@/stores/globalSessions';
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
type LoadResult = {
activeSessions: Session[];
archivedSessions: Session[];
};
type GlobalSessionsState = {
activeSessions: Session[];
archivedSessions: Session[];
sessionsByDirectory: Map<string, Session[]>;
hasLoaded: boolean;
status: GlobalSessionsStatus;
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void;
upsertSession: (session: Session) => void;
removeSessions: (ids: Iterable<string>) => void;
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
};
const PAGE_SIZE = 200;
let inflightLoad: Promise<LoadResult> | null = null;
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const replaced = trimmed.replace(/\\/g, '/');
if (replaced === '/') {
return '/';
}
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
export const resolveGlobalSessionDirectory = (session: Session): string | null => {
const record = session as Session & {
directory?: string | null;
project?: { worktree?: string | null } | null;
};
return normalizePath(record.directory ?? null)
?? normalizePath(record.project?.worktree ?? null);
};
const buildSessionsByDirectory = (sessions: Session[]): Map<string, Session[]> => {
const next = new Map<string, Session[]>();
for (const session of sessions) {
const directory = resolveGlobalSessionDirectory(session);
if (!directory) {
continue;
}
const existing = next.get(directory);
if (existing) {
existing.push(session);
continue;
}
next.set(directory, [session]);
}
return next;
};
const getSessionSignature = (session: Session): string => {
return [
session.id,
session.title ?? '',
session.time?.created ?? 0,
session.time?.updated ?? 0,
session.time?.archived ?? 0,
session.share ? 1 : 0,
resolveGlobalSessionDirectory(session) ?? '',
].join(':');
};
const sameSessionList = (prev: Session[], next: Session[]): boolean => {
if (prev === next) {
return true;
}
if (prev.length !== next.length) {
return false;
}
for (let index = 0; index < prev.length; index += 1) {
if (getSessionSignature(prev[index]) !== getSessionSignature(next[index])) {
return false;
}
}
return true;
};
const upsertSessionIntoList = (sessions: Session[], session: Session): Session[] => {
const index = sessions.findIndex((candidate) => candidate.id === session.id);
if (index === -1) {
return [session, ...sessions];
}
if (getSessionSignature(sessions[index]) === getSessionSignature(session)) {
return sessions;
}
const next = [...sessions];
next[index] = session;
return next;
};
const applySnapshot = (
state: GlobalSessionsState,
activeSessions: Session[],
archivedSessions: Session[],
status: GlobalSessionsStatus,
): Partial<GlobalSessionsState> | GlobalSessionsState => {
const nextActiveSessions = sameSessionList(state.activeSessions, activeSessions)
? state.activeSessions
: activeSessions;
const nextArchivedSessions = sameSessionList(state.archivedSessions, archivedSessions)
? state.archivedSessions
: archivedSessions;
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions);
if (
nextActiveSessions === state.activeSessions
&& nextArchivedSessions === state.archivedSessions
&& nextSessionsByDirectory === state.sessionsByDirectory
&& state.hasLoaded
&& state.status === status
) {
return state;
}
return {
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: nextSessionsByDirectory,
hasLoaded: true,
status,
};
};
export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({
activeSessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
hasLoaded: false,
status: 'idle',
applySnapshot: (activeSessions, archivedSessions, status = 'ready') => {
set((state) => applySnapshot(state, activeSessions, archivedSessions, status));
},
loadSessions: async (fallbackActive) => {
if (inflightLoad) {
return inflightLoad;
}
set((state) => (state.status === 'loading' ? state : { status: 'loading' }));
inflightLoad = (async () => {
const current = get();
try {
const sdk = opencodeClient.getSdkClient();
const [activeResult, archivedResult] = await Promise.allSettled([
listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }),
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }),
]);
const nextActiveSessions = activeResult.status === 'fulfilled'
? activeResult.value
: (fallbackActive ?? current.activeSessions);
const nextArchivedSessions = archivedResult.status === 'fulfilled'
? archivedResult.value
: current.archivedSessions;
if (activeResult.status === 'rejected') {
console.warn('[GlobalSessions] Failed to load active sessions, using fallback:', activeResult.reason);
}
if (archivedResult.status === 'rejected') {
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
}
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready'));
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
} catch (error) {
const nextActiveSessions = fallbackActive ?? current.activeSessions;
const nextArchivedSessions = current.archivedSessions;
console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error);
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'error'));
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
},
upsertSession: (session) => {
set((state) => {
const isArchived = Boolean(session.time?.archived);
const nextActiveSessions = isArchived
? state.activeSessions.filter((candidate) => candidate.id !== session.id)
: upsertSessionIntoList(state.activeSessions, session);
const nextArchivedSessions = isArchived
? upsertSessionIntoList(state.archivedSessions, session)
: state.archivedSessions.filter((candidate) => candidate.id !== session.id);
if (
nextActiveSessions === state.activeSessions
&& nextArchivedSessions === state.archivedSessions
) {
return state;
}
return {
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: nextActiveSessions === state.activeSessions
? state.sessionsByDirectory
: buildSessionsByDirectory(nextActiveSessions),
};
});
},
removeSessions: (ids) => {
const idSet = ids instanceof Set ? ids : new Set(ids);
if (idSet.size === 0) {
return;
}
set((state) => {
const nextActiveSessions = state.activeSessions.filter((session) => !idSet.has(session.id));
const nextArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id));
if (
nextActiveSessions.length === state.activeSessions.length
&& nextArchivedSessions.length === state.archivedSessions.length
) {
return state;
}
return {
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
};
});
},
archiveSessions: (ids, archivedAt = Date.now()) => {
const idSet = ids instanceof Set ? ids : new Set(ids);
if (idSet.size === 0) {
return;
}
set((state) => {
const movedSessions: Session[] = [];
const nextActiveSessions = state.activeSessions.filter((session) => {
if (!idSet.has(session.id)) {
return true;
}
movedSessions.push({
...session,
time: {
...session.time,
archived: archivedAt,
},
});
return false;
});
if (movedSessions.length === 0) {
return state;
}
const remainingArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id));
return {
activeSessions: nextActiveSessions,
archivedSessions: [...movedSessions, ...remainingArchivedSessions],
sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions),
};
});
},
}));
export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Promise<LoadResult> => {
const state = useGlobalSessionsStore.getState();
if (state.hasLoaded && state.status !== 'error') {
return {
activeSessions: state.activeSessions,
archivedSessions: state.archivedSessions,
};
}
return state.loadSessions(fallbackActive);
};
export const refreshGlobalSessions = async (fallbackActive?: Session[]): Promise<LoadResult> => {
return useGlobalSessionsStore.getState().loadSessions(fallbackActive);
};
+4 -8
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { devtools } from 'zustand/middleware';
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
import { opencodeClient } from '@/lib/opencode/client';
@@ -7,7 +8,7 @@ import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { checkIsGitRepository } from '@/lib/gitApi';
import { useSessionStore } from './sessionStore';
// sessionStore removed — sync bootstrap handles session loading
import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
@@ -186,7 +187,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
() => opencodeClient.createSession({ title: sessionTitle })
);
useSessionStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
useSessionUIStore.getState().setWorktreeMetadata(session.id, enrichedMetadata);
createdRuns.push({
sessionId: session.id,
@@ -228,12 +229,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
url: f.url,
}));
// Refresh sessions list so sidebar shows the new sessions immediately
try {
await useSessionStore.getState().loadSessions();
} catch {
// Ignore refresh errors
}
// Session list refresh handled by sync bootstrap via SSE events
// Setup commands run via SDK worktree startCommand.
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import { useSessionStore } from "./useSessionStore";
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
export type TodoPriority = "high" | "medium" | "low";
export interface TodoItem {
id: string;
content: string;
status: TodoStatus;
priority: TodoPriority;
}
interface TodoStore {
// Map of sessionId -> todos
sessionTodos: Map<string, TodoItem[]>;
isLoading: boolean;
// Actions
loadTodos: (sessionId: string) => Promise<void>;
updateTodos: (sessionId: string, todos: TodoItem[]) => void;
getTodosForSession: (sessionId: string) => TodoItem[];
clearTodos: (sessionId: string) => void;
}
type RawTodo = { id: string; content: string; status: string; priority: string };
const normalizeTodo = (todo: RawTodo): TodoItem => ({
id: todo.id,
content: todo.content,
status: (todo.status as TodoStatus) || "pending",
priority: (todo.priority as TodoPriority) || "medium",
});
export const useTodoStore = create<TodoStore>()(
devtools(
(set, get) => ({
sessionTodos: new Map(),
isLoading: false,
loadTodos: async (sessionId: string) => {
if (!sessionId) return;
set({ isLoading: true });
try {
const directory = useSessionStore.getState().getDirectoryForSession(sessionId);
const rawTodos = directory
? await opencodeClient.withDirectory(directory, () => opencodeClient.getSessionTodos(sessionId))
: await opencodeClient.getSessionTodos(sessionId);
const todos = rawTodos.map(normalizeTodo);
set((state) => {
const newMap = new Map(state.sessionTodos);
newMap.set(sessionId, todos);
return { sessionTodos: newMap, isLoading: false };
});
} catch (error) {
console.warn("[TodoStore] Failed to load todos:", error);
set({ isLoading: false });
}
},
updateTodos: (sessionId: string, todos: TodoItem[]) => {
set((state) => {
const newMap = new Map(state.sessionTodos);
newMap.set(sessionId, todos);
return { sessionTodos: newMap };
});
},
getTodosForSession: (sessionId: string) => {
return get().sessionTodos.get(sessionId) || [];
},
clearTodos: (sessionId: string) => {
set((state) => {
const newMap = new Map(state.sessionTodos);
newMap.delete(sessionId);
return { sessionTodos: newMap };
});
},
}),
{ name: "todo-store" }
)
);
// Helper to handle SSE todo.updated events
export const handleTodoUpdatedEvent = (
sessionId: string,
todos: RawTodo[]
): void => {
const normalizedTodos = todos.map(normalizeTodo);
useTodoStore.getState().updateTodos(sessionId, normalizedTodos);
};
+9
View File
@@ -12,6 +12,7 @@ export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live';
export type ActivityRenderMode = 'collapsed' | 'summary';
export type SessionRetentionAction = 'archive' | 'delete';
type ContextPanelTab = {
id: string;
@@ -503,6 +504,7 @@ interface UIStore {
showDeletionDialog: boolean;
autoDeleteEnabled: boolean;
autoDeleteAfterDays: number;
sessionRetentionAction: SessionRetentionAction;
autoDeleteLastRunAt: number | null;
messageLimit: number;
fontSize: number;
@@ -617,6 +619,7 @@ interface UIStore {
setShowDeletionDialog: (value: boolean) => void;
setAutoDeleteEnabled: (value: boolean) => void;
setAutoDeleteAfterDays: (days: number) => void;
setSessionRetentionAction: (value: SessionRetentionAction) => void;
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
setMessageLimit: (value: number) => void;
setFontSize: (size: number) => void;
@@ -730,6 +733,7 @@ export const useUIStore = create<UIStore>()(
showDeletionDialog: true,
autoDeleteEnabled: false,
autoDeleteAfterDays: 30,
sessionRetentionAction: 'archive',
autoDeleteLastRunAt: null,
messageLimit: 200,
fontSize: 100,
@@ -1313,6 +1317,10 @@ export const useUIStore = create<UIStore>()(
set({ autoDeleteAfterDays: clampedDays });
},
setSessionRetentionAction: (value) => {
set({ sessionRetentionAction: value });
},
setAutoDeleteLastRunAt: (timestamp) => {
set({ autoDeleteLastRunAt: timestamp });
},
@@ -1835,6 +1843,7 @@ export const useUIStore = create<UIStore>()(
showDeletionDialog: state.showDeletionDialog,
autoDeleteEnabled: state.autoDeleteEnabled,
autoDeleteAfterDays: state.autoDeleteAfterDays,
sessionRetentionAction: state.sessionRetentionAction,
autoDeleteLastRunAt: state.autoDeleteLastRunAt,
messageLimit: state.messageLimit,
fontSize: state.fontSize,
@@ -214,3 +214,73 @@ export const normalizeStreamingPart = (incoming: Part, existing?: Part): Part =>
return normalized as Part;
};
const deepEqualRecord = (left: Record<string, unknown>, right: Record<string, unknown>): boolean => {
const keys = new Set<string>([
...Object.keys(left),
...Object.keys(right),
]);
for (const key of keys) {
const leftValue = left[key];
const rightValue = right[key];
if (Array.isArray(leftValue) || Array.isArray(rightValue)) {
if (!Array.isArray(leftValue) || !Array.isArray(rightValue) || leftValue.length !== rightValue.length) {
return false;
}
for (let index = 0; index < leftValue.length; index += 1) {
if (!deepEqualUnknown(leftValue[index], rightValue[index])) {
return false;
}
}
continue;
}
if (!deepEqualUnknown(leftValue, rightValue)) {
return false;
}
}
return true;
};
const deepEqualUnknown = (left: unknown, right: unknown): boolean => {
if (left === right) {
return true;
}
if (!left || !right) {
return false;
}
if (typeof left !== typeof right) {
return false;
}
if (typeof left === 'object' && typeof right === 'object') {
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (!deepEqualUnknown(left[index], right[index])) {
return false;
}
}
return true;
}
return deepEqualRecord(left as Record<string, unknown>, right as Record<string, unknown>);
}
return false;
};
export const arePartsEquivalent = (left: Part | undefined, right: Part | undefined): boolean => {
if (!left || !right) {
return left === right;
}
return deepEqualUnknown(left, right);
};
+238
View File
@@ -15,3 +15,241 @@ export const sessionStatusDebugEnabled = (): boolean => {
return false;
}
};
const STREAM_PERF_STORAGE_KEY = 'openchamber_stream_perf';
type PerfCounter = {
count: number;
total: number;
max: number;
last: number;
};
type StreamPerfState = {
counters: Map<string, PerfCounter>;
startedAt: number;
lastUpdatedAt: number;
};
export type StreamPerfEntry = {
metric: string;
count: number;
avg: number;
max: number;
total: number;
last: number;
};
export type StreamPerfSnapshot = {
enabled: boolean;
startedAt: number | null;
lastUpdatedAt: number | null;
durationMs: number;
entries: StreamPerfEntry[];
};
declare global {
interface Window {
__openchamberStreamPerfState?: StreamPerfState;
__openchamberVsCodeStreamPerfState?: {
counters: Map<string, PerfCounter>;
lastReportAt?: number;
lastUpdatedAt?: number;
reportTimer?: number | null;
startedAt?: number;
};
}
}
export const streamPerfEnabled = (): boolean => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1';
} catch {
return false;
}
};
const nowMs = (): number => {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
};
const ensureStreamPerfState = (): StreamPerfState | null => {
if (!streamPerfEnabled() || typeof window === 'undefined') {
return null;
}
if (!window.__openchamberStreamPerfState) {
const startedAt = Date.now();
window.__openchamberStreamPerfState = {
counters: new Map<string, PerfCounter>(),
startedAt,
lastUpdatedAt: startedAt,
};
}
return window.__openchamberStreamPerfState;
};
const normalizePerfEntries = (counters: Map<string, PerfCounter>): StreamPerfEntry[] => {
return Array.from(counters.entries())
.map(([metric, bucket]) => ({
metric,
count: bucket.count,
avg: bucket.count > 0 ? Number((bucket.total / bucket.count).toFixed(3)) : 0,
max: Number(bucket.max.toFixed(3)),
total: Number(bucket.total.toFixed(3)),
last: Number(bucket.last.toFixed(3)),
}))
.sort((a, b) => b.total - a.total || b.count - a.count);
};
const updatePerfCounter = (metric: string, amount: number): void => {
const state = ensureStreamPerfState();
if (!state) {
return;
}
const bucket = state.counters.get(metric) ?? { count: 0, total: 0, max: 0, last: 0 };
bucket.count += 1;
bucket.total += amount;
bucket.max = Math.max(bucket.max, amount);
bucket.last = amount;
state.counters.set(metric, bucket);
state.lastUpdatedAt = Date.now();
};
export const setStreamPerfEnabled = (enabled: boolean): void => {
if (typeof window === 'undefined') {
return;
}
try {
if (enabled) {
window.localStorage.setItem(STREAM_PERF_STORAGE_KEY, '1');
window.__openchamberStreamPerfState = {
counters: new Map<string, PerfCounter>(),
startedAt: Date.now(),
lastUpdatedAt: Date.now(),
};
return;
}
window.localStorage.removeItem(STREAM_PERF_STORAGE_KEY);
delete window.__openchamberStreamPerfState;
delete window.__openchamberVsCodeStreamPerfState;
} catch {
// ignore storage failures in debug helper
}
};
export const resetStreamPerf = (): void => {
if (typeof window === 'undefined') {
return;
}
if (streamPerfEnabled()) {
window.__openchamberStreamPerfState = {
counters: new Map<string, PerfCounter>(),
startedAt: Date.now(),
lastUpdatedAt: Date.now(),
};
}
if (window.__openchamberVsCodeStreamPerfState) {
window.__openchamberVsCodeStreamPerfState = {
...window.__openchamberVsCodeStreamPerfState,
counters: new Map<string, PerfCounter>(),
startedAt: Date.now(),
lastUpdatedAt: Date.now(),
};
}
};
export const getStreamPerfSnapshot = (): StreamPerfSnapshot => {
if (typeof window === 'undefined') {
return {
enabled: false,
startedAt: null,
lastUpdatedAt: null,
durationMs: 0,
entries: [],
};
}
const state = window.__openchamberStreamPerfState;
if (!streamPerfEnabled() || !state) {
return {
enabled: false,
startedAt: null,
lastUpdatedAt: null,
durationMs: 0,
entries: [],
};
}
return {
enabled: true,
startedAt: state.startedAt,
lastUpdatedAt: state.lastUpdatedAt,
durationMs: Math.max(0, Date.now() - state.startedAt),
entries: normalizePerfEntries(state.counters),
};
};
export const getVsCodeStreamPerfSnapshot = (): StreamPerfSnapshot => {
if (typeof window === 'undefined') {
return {
enabled: false,
startedAt: null,
lastUpdatedAt: null,
durationMs: 0,
entries: [],
};
}
const state = window.__openchamberVsCodeStreamPerfState;
if (!streamPerfEnabled() || !state) {
return {
enabled: false,
startedAt: null,
lastUpdatedAt: null,
durationMs: 0,
entries: [],
};
}
const startedAt = typeof state.startedAt === 'number' ? state.startedAt : null;
const lastUpdatedAt = typeof state.lastUpdatedAt === 'number' ? state.lastUpdatedAt : null;
return {
enabled: true,
startedAt,
lastUpdatedAt,
durationMs: startedAt ? Math.max(0, Date.now() - startedAt) : 0,
entries: normalizePerfEntries(state.counters),
};
};
export const streamPerfCount = (metric: string, count = 1): void => {
updatePerfCounter(metric, count);
};
export const streamPerfObserve = (metric: string, value: number): void => {
updatePerfCounter(metric, value);
};
export const streamPerfMeasure = <T>(metric: string, fn: () => T): T => {
if (!streamPerfEnabled()) {
return fn();
}
const start = nowMs();
try {
return fn();
} finally {
updatePerfCounter(metric, nowMs() - start);
}
};