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
+70 -34
View File
@@ -1,9 +1,9 @@
import React from 'react';
import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2';
import { useShallow } from 'zustand/react/shallow';
import type { MessageStreamPhase } from '@/stores/types/sessionTypes';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectorySync, useSessionPermissions, useSessionStatus } from '@/sync/sync-context';
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
import { useCurrentSessionActivity } from './useSessionActivity';
@@ -52,6 +52,11 @@ interface AssistantSessionMessageRecord {
parts: Part[];
}
type SessionMessageRecord = {
info: Message;
parts: Part[];
};
const DEFAULT_WORKING: WorkingSummary = {
activity: 'idle',
hasWorkingContext: false,
@@ -74,6 +79,9 @@ const DEFAULT_WORKING: WorkingSummary = {
retryInfo: null,
};
const EMPTY_MESSAGES: Message[] = [];
const EMPTY_PARTS: Part[] = [];
const EMPTY_SESSION_MESSAGES: SessionMessageRecord[] = [];
const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant';
const isReasoningPart = (part: Part): part is ReasoningPart => part.type === 'reasoning';
@@ -114,36 +122,68 @@ const getToolDisplayName = (part: ToolPart): string => {
};
export function useAssistantStatus(): AssistantStatusSnapshot {
const { currentSessionId, messages, permissions, sessionAbortFlags } = useSessionStore(
useShallow((state) => ({
currentSessionId: state.currentSessionId,
messages: state.messages,
permissions: state.permissions,
sessionAbortFlags: state.sessionAbortFlags,
}))
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const rawSessionMessages = useDirectorySync(
React.useCallback((state) => {
if (!currentSessionId) {
return EMPTY_MESSAGES;
}
return state.message[currentSessionId] ?? EMPTY_MESSAGES;
}, [currentSessionId])
);
// Only subscribe to parts for the last assistant message — avoids re-render
// on every part delta for earlier messages.
const lastAssistantId = React.useMemo(() => {
for (let i = rawSessionMessages.length - 1; i >= 0; i--) {
if (rawSessionMessages[i].role === 'assistant') return rawSessionMessages[i].id;
}
return null;
}, [rawSessionMessages]);
const lastAssistantParts = useDirectorySync(
React.useCallback((state) => {
if (!lastAssistantId) return EMPTY_PARTS;
return state.part[lastAssistantId] ?? EMPTY_PARTS;
}, [lastAssistantId])
);
const sessionMessages = React.useMemo<SessionMessageRecord[]>(
() => {
if (rawSessionMessages.length === 0) {
return EMPTY_SESSION_MESSAGES;
}
return rawSessionMessages.map((msg) => ({
info: msg,
parts: msg.id === lastAssistantId ? lastAssistantParts : EMPTY_PARTS,
}));
},
[lastAssistantParts, rawSessionMessages, lastAssistantId]
);
const sessionPermissionRequests = useSessionPermissions(currentSessionId ?? '');
const sessionAbortRecord = useSessionUIStore(
React.useCallback((state) => {
if (!currentSessionId) {
return null;
}
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
}, [currentSessionId])
);
const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity();
const sessionRetryAttempt = useSessionStore((state) => {
if (!currentSessionId || !state.sessionStatus) return undefined;
const s = state.sessionStatus.get(currentSessionId);
return s?.type === 'retry' ? s.attempt : undefined;
});
const currentSessionStatus = useSessionStatus(currentSessionId ?? '');
const sessionRetryNext = useSessionStore((state) => {
if (!currentSessionId || !state.sessionStatus) return undefined;
const s = state.sessionStatus.get(currentSessionId);
return s?.type === 'retry' ? s.next : undefined;
});
const sessionRetryAttempt = currentSessionStatus?.type === 'retry'
? (currentSessionStatus as { type: 'retry'; attempt?: number }).attempt
: undefined;
const sessionMessages = React.useMemo<Array<{ info: Message; parts: Part[] }>>(() => {
if (!currentSessionId) {
return [];
}
const records = messages.get(currentSessionId) ?? [];
return records as Array<{ info: Message; parts: Part[] }>;
}, [currentSessionId, messages]);
const sessionRetryNext = currentSessionStatus?.type === 'retry'
? (currentSessionStatus as { type: 'retry'; next?: number }).next
: undefined;
type ParsedStatusResult = {
activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined;
@@ -287,11 +327,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
}, [sessionMessages]);
const abortState = React.useMemo(() => {
const sessionId = currentSessionId;
const abortRecord = sessionId ? sessionAbortFlags?.get(sessionId) ?? null : null;
const hasActiveAbort = Boolean(abortRecord && !abortRecord.acknowledged);
const hasActiveAbort = Boolean(sessionAbortRecord && !sessionAbortRecord.acknowledged);
return { wasAborted: hasActiveAbort, abortActive: hasActiveAbort };
}, [currentSessionId, sessionAbortFlags]);
}, [sessionAbortRecord]);
const baseWorking = React.useMemo<WorkingSummary>(() => {
@@ -388,9 +426,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
return baseWorking;
}
const sessionId = currentSessionId;
const permissionList = sessionId ? permissions?.get(sessionId) ?? [] : [];
const hasPendingPermission = permissionList.length > 0;
const hasPendingPermission = sessionPermissionRequests.length > 0;
if (!hasPendingPermission) {
return baseWorking;
@@ -403,7 +439,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
canAbort: false,
retryInfo: null,
};
}, [currentSessionId, permissions, baseWorking]);
}, [baseWorking, sessionPermissionRequests]);
return {
forming,
+19 -28
View File
@@ -27,7 +27,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { getSyncMessages, getSyncParts } from '@/sync/sync-refs';
import { useConfigStore } from '@/stores/useConfigStore';
import { useServerTTS } from './useServerTTS';
import { useSayTTS } from './useSayTTS';
@@ -120,18 +122,16 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
const isActiveRef = useRef(false);
const processingMessageRef = useRef(false);
const lastTranscriptRef = useRef('');
const messagesRef = useRef<Map<string, { info: { role: string }; parts: Array<{ type: string; text?: string }> }>>(new Map());
const pendingResumeOnVisibleRef = useRef(false);
const pendingFinalTranscriptRef = useRef('');
const finalTranscriptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const deviceChangeRestartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Store access
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const sendMessage = useSessionStore((s) => s.sendMessage);
const setPendingInputText = useSessionStore((s) => s.setPendingInputText);
const messages = useSessionStore((s) => s.messages);
const createSession = useSessionStore((s) => s.createSession);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const sendMessage = useSessionUIStore((s) => s.sendMessage);
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
const createSession = useSessionUIStore((s) => s.createSession);
const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore();
const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai';
@@ -147,16 +147,6 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
enabled: shouldCheckSayAvailability,
});
// Update messages ref when messages change
useEffect(() => {
if (currentSessionId) {
const sessionMessages = messages.get(currentSessionId);
if (sessionMessages) {
messagesRef.current = new Map(sessionMessages.map(m => [m.info.id, m]));
}
}
}, [messages, currentSessionId]);
// Stop voice when session changes to prevent microphone from staying active
// This ensures voice mode doesn't carry over between sessions
const prevSessionIdRef = useRef<string | null>(null);
@@ -374,22 +364,23 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
// Wait for AI response and speak it
// We'll poll for new assistant messages
const checkForResponse = async () => {
if (!isActiveRef.current) return;
const sessionMessages = messagesRef.current;
const assistantMessages = Array.from(sessionMessages.values())
.filter(m => m.info.role === 'assistant')
if (!isActiveRef.current || !sessionId) return;
const rawMessages = getSyncMessages(sessionId);
const assistantMessages = rawMessages
.filter(m => m.role === 'assistant')
.sort((a, b) => {
const aTime = (a.info as { time?: { created?: number } }).time?.created ?? 0;
const bTime = (b.info as { time?: { created?: number } }).time?.created ?? 0;
const aTime = (a as { time?: { created?: number } }).time?.created ?? 0;
const bTime = (b as { time?: { created?: number } }).time?.created ?? 0;
return bTime - aTime;
});
if (assistantMessages.length > 0) {
const latestMessage = assistantMessages[0];
const textParts = latestMessage.parts
.filter(p => p.type === 'text')
.map(p => p.text)
const parts = getSyncParts(latestMessage.id);
const textParts = parts
.filter((p: { type: string; text?: string }) => p.type === 'text')
.map((p: { type: string; text?: string }) => p.text ?? '')
.join(' ');
if (textParts.trim()) {
+22 -7
View File
@@ -11,8 +11,6 @@ import {
import { useScrollEngine } from './useScrollEngine';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
export type ContentChangeReason = 'text' | 'structural' | 'permission';
interface ChatMessageRecord {
@@ -41,7 +39,6 @@ interface UseChatScrollManagerOptions {
isSyncing: boolean;
isMobile: boolean;
chatRenderMode?: 'sorted' | 'live';
messageStreamStates: Map<string, unknown>;
onActiveTurnChange?: (turnId: string | null) => void;
}
@@ -110,6 +107,7 @@ export const useChatScrollManager = ({
const lastScrollTopRef = React.useRef<number>(0);
const touchLastYRef = React.useRef<number | null>(null);
const pinnedSyncRafRef = React.useRef<number | null>(null);
const preferInstantPinRef = React.useRef(false);
const viewportAnchorTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const lastViewportAnchorRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
@@ -178,10 +176,20 @@ export const useChatScrollManager = ({
}
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom <= getAutoFollowThreshold()) {
preferInstantPinRef.current = false;
return;
}
if (preferInstantPinRef.current) {
scrollToBottomInternal({ instant: true });
return;
}
if (distanceFromBottom > getAutoFollowThreshold()) {
scrollPinnedToBottom();
}
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, scrollToBottomInternal, updateScrollButtonVisibility]);
const schedulePinnedStateAndIndicators = React.useCallback(() => {
if (typeof window === 'undefined') {
@@ -264,6 +272,7 @@ export const useChatScrollManager = ({
const releasePinnedScroll = React.useCallback(() => {
scrollEngine.cancelFollow();
preferInstantPinRef.current = false;
updatePinnedState(false);
schedulePinnedStateAndIndicators();
}, [schedulePinnedStateAndIndicators, scrollEngine, updatePinnedState]);
@@ -296,6 +305,7 @@ export const useChatScrollManager = ({
if (!isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom <= getPinThreshold()) {
preferInstantPinRef.current = false;
updatePinnedState(true);
}
}
@@ -412,7 +422,7 @@ export const useChatScrollManager = ({
}, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]);
// Session switch - always start pinned at bottom
useIsomorphicLayoutEffect(() => {
React.useEffect(() => {
if (!currentSessionId || currentSessionId === lastSessionIdRef.current) {
return;
}
@@ -423,6 +433,7 @@ export const useChatScrollManager = ({
pendingViewportAnchorRef.current = null;
// Always start pinned at bottom on session switch
preferInstantPinRef.current = true;
updatePinnedState(true);
setShowScrollButton(false);
@@ -534,12 +545,17 @@ export const useChatScrollManager = ({
const container = scrollRef.current;
if (!container) {
onActiveTurnChange(null);
return;
}
let lastActiveTurnId: string | null = null;
const spy = createScrollSpy({
onActive: (turnId) => {
if (turnId === lastActiveTurnId) {
return;
}
lastActiveTurnId = turnId;
onActiveTurnChange(turnId);
},
});
@@ -626,7 +642,6 @@ export const useChatScrollManager = ({
container.removeEventListener('scroll', handleScroll);
mutationObserver.disconnect();
spy.destroy();
onActiveTurnChange(null);
};
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]);
@@ -1,13 +1,14 @@
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import type { Session } from '@opencode-ai/sdk/v2';
export const useChatSearchDirectory = (): string | undefined => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const worktreeMap = useSessionStore((state) => state.worktreeMetadata);
const newSessionDraft = useSessionStore((state) => state.newSessionDraft);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata);
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
+9 -10
View File
@@ -1,27 +1,26 @@
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import type { Session } from '@opencode-ai/sdk/v2';
/**
* Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal).
*
*
* Priority order:
* 1. Worktree metadata path (for worktree sessions)
* 2. Session directory (for active sessions)
* 3. Draft session directoryOverride (when creating a new session)
* 4. Fallback directory from DirectoryStore
*
*
* This ensures that tabs show content from the correct project directory
* even when a draft session is being created.
*/
export const useEffectiveDirectory = (): string | undefined => {
const {
currentSessionId,
sessions,
worktreeMetadata: worktreeMap,
newSessionDraft,
} = useSessionStore();
const { currentDirectory: fallbackDirectory } = useDirectoryStore();
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const sessions = useSessions();
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
// If we have an active session, use its directory
if (currentSessionId) {
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,13 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { RuntimeAPIs } from '@/lib/api/types';
import { mapWithConcurrency } from '@/lib/concurrency';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
const MAX_BACKGROUND_PR_DIRECTORIES = 20;
const ACTIVE_DIRECTORY_REFRESH_TTL_MS = 15_000;
@@ -94,34 +96,6 @@ const prioritizeDirectoriesForFetch = (
});
};
const mapWithConcurrency = async <T, R>(
values: T[],
concurrency: number,
mapper: (value: T) => Promise<R>,
): Promise<R[]> => {
if (values.length === 0) {
return [];
}
const safeConcurrency = Math.max(1, Math.min(concurrency, values.length));
const results = new Array<R>(values.length);
let cursor = 0;
const worker = async () => {
while (true) {
const nextIndex = cursor;
cursor += 1;
if (nextIndex >= values.length) {
return;
}
results[nextIndex] = await mapper(values[nextIndex]);
}
};
await Promise.all(Array.from({ length: safeConcurrency }, () => worker()));
return results;
};
const toPrTargets = (cache: Map<string, BranchCacheEntry>, directories: string[]): PrTarget[] => {
const result: PrTarget[] = [];
directories.forEach((directory) => {
@@ -144,10 +118,9 @@ export const useGitHubPrBackgroundTracking = (
): void => {
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const projects = useProjectsStore((state) => state.projects);
const sessions = useSessionStore((state) => state.sessions);
const archivedSessions = useSessionStore((state) => state.archivedSessions);
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
const sessions = useSessions();
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -229,7 +202,7 @@ export const useGitHubPrBackgroundTracking = (
add(metadata.path);
});
[...sessions, ...archivedSessions]
[...sessions]
.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
.forEach((rawSession) => {
const session = rawSession as SessionLike;
@@ -238,7 +211,7 @@ export const useGitHubPrBackgroundTracking = (
});
return Array.from(ordered.values()).slice(0, MAX_BACKGROUND_PR_DIRECTORIES);
}, [archivedSessions, availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]);
}, [availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]);
React.useEffect(() => {
let cancelled = false;
@@ -275,7 +248,7 @@ export const useGitHubPrBackgroundTracking = (
STATUS_FETCH_CONCURRENCY,
async (directory) => {
try {
const status = await git.getGitStatus(directory);
const status = await git.getGitStatus(directory, { mode: 'light' });
const branch = typeof status.current === 'string' ? status.current.trim() : '';
return {
directory,
@@ -383,7 +356,11 @@ export const useGitHubPrBackgroundTracking = (
}
};
void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME });
// Delay initial PR tracking to avoid startup CPU burst
const startupDelayId = window.setTimeout(() => {
if (cancelled) return;
void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME });
}, 5_000);
const intervalId = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
@@ -442,6 +419,7 @@ export const useGitHubPrBackgroundTracking = (
return () => {
cancelled = true;
window.clearTimeout(startupDelayId);
window.clearInterval(intervalId);
window.removeEventListener('focus', refreshOnResume);
document.removeEventListener('visibilitychange', refreshOnResume);
+16 -6
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { useGitStore } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions, useSessionStatus } from '@/sync/sync-context';
/**
* Background git polling hook - monitors git status regardless of which tab is open.
@@ -20,8 +21,17 @@ export function useGitPolling() {
const { git } = useRuntimeAPIs();
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
const { currentSessionId, sessions, worktreeMetadata: worktreeMap, sessionStatus } = useSessionStore();
const { setActiveDirectory, startPolling, setPollingMode, stopPolling, fetchAll, fetchStatus, clearDiffCache } = useGitStore();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const worktreeMap = useSessionUIStore((state) => state.worktreeMetadata);
const currentStatus = useSessionStatus(currentSessionId ?? '');
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const startPolling = useGitStore((state) => state.startPolling);
const setPollingMode = useGitStore((state) => state.setPollingMode);
const stopPolling = useGitStore((state) => state.stopPolling);
const fetchAll = useGitStore((state) => state.fetchAll);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const clearDiffCache = useGitStore((state) => state.clearDiffCache);
const immediateRefreshTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const lastImmediateRefreshAtRef = React.useRef<number>(0);
@@ -40,12 +50,12 @@ export function useGitPolling() {
if (!currentSessionId) {
return 'idle';
}
const activeStatus = sessionStatus?.get(currentSessionId)?.type;
const activeStatus = currentStatus?.type;
if (activeStatus === 'busy' || activeStatus === 'retry') {
return activeStatus;
}
return 'idle';
}, [currentSessionId, sessionStatus]);
}, [currentSessionId, currentStatus]);
const pollingMode = activeSessionStatus === 'busy' || activeSessionStatus === 'retry' ? 'busy' : 'normal';
@@ -84,7 +94,7 @@ export function useGitPolling() {
immediateRefreshTimerRef.current = null;
lastImmediateRefreshAtRef.current = Date.now();
void (async () => {
const statusChanged = await fetchStatus(targetDirectory, git, { silent: true });
const statusChanged = await fetchStatus(targetDirectory, git, { silent: true, mode: 'light' });
if (shouldForceDiffRefresh && !statusChanged) {
clearDiffCache(targetDirectory);
}
+26 -30
View File
@@ -1,5 +1,7 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { useUIStore } from '@/stores/useUIStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
@@ -10,24 +12,26 @@ import { showOpenCodeStatus } from '@/lib/openCodeStatus';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
export const useKeyboardShortcuts = () => {
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
const {
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
toggleRightSidebar,
setRightSidebarOpen,
setRightSidebarTab,
toggleBottomTerminal,
setBottomTerminalExpanded,
isMobile,
setSessionSwitcherOpen,
setActiveMainTab,
setSettingsDialogOpen,
setModelSelectorOpen,
toggleExpandedInput,
shortcutOverrides,
} = useUIStore();
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const armAbortPrompt = useSessionUIStore((s) => s.armAbortPrompt);
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const abortCurrentOperation = sessionActions.abortCurrentOperation;;
const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen);
const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab);
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded);
const isMobile = useUIStore((s) => s.isMobile);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen);
const toggleExpandedInput = useUIStore((s) => s.toggleExpandedInput);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const { themeMode, setThemeMode } = useThemeSystem();
const { working } = useAssistantStatus();
const abortPrimedUntilRef = React.useRef<number | null>(null);
@@ -108,13 +112,6 @@ export const useKeyboardShortcuts = () => {
return;
}
if (eventMatchesShortcut(e, combo('open_timeline'))) {
e.preventDefault();
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
setTimelineDialogOpen(!isTimelineDialogOpen);
return;
}
if (eventMatchesShortcut(e, combo('open_settings'))) {
e.preventDefault();
const { isSettingsDialogOpen } = useUIStore.getState();
@@ -270,14 +267,13 @@ export const useKeyboardShortcuts = () => {
configState.cycleCurrentVariant();
const nextVariant = useConfigStore.getState().currentVariant;
const sessionState = useSessionStore.getState();
const sessionId = sessionState.currentSessionId;
const sessionId = useSessionUIStore.getState().currentSessionId;
const agentName = useConfigStore.getState().currentAgentName;
const providerId = useConfigStore.getState().currentProviderId;
const modelId = useConfigStore.getState().currentModelId;
if (sessionId && agentName && providerId && modelId) {
sessionState.saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
}
return;
@@ -387,7 +383,7 @@ export const useKeyboardShortcuts = () => {
if (primedUntil && now < primedUntil) {
e.preventDefault();
resetAbortPriming();
void abortCurrentOperation(sessionId || undefined);
void abortCurrentOperation(sessionId ?? '');
return;
}
+9 -11
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
@@ -48,16 +48,14 @@ type MenuAction =
export const useMenuActions = (
onToggleMemoryDebug?: () => void
) => {
const { openNewSessionDraft } = useSessionStore();
const {
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setActiveMainTab,
setSettingsDialogOpen,
setAboutDialogOpen,
} = useUIStore();
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const { addProject } = useProjectsStore();
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
const { requestAccess, startAccessing } = useFileSystemAccess();
+4 -3
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { isWebRuntime } from '@/lib/desktop';
import { PWA_RECENT_SESSIONS_STORAGE_KEY } from '@/lib/pwa';
@@ -60,8 +61,8 @@ const buildRecentShortcuts = (
};
export const usePwaManifestSync = () => {
const sessions = useSessionStore((state) => state.sessions);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const recentShortcuts = React.useMemo(() => {
return buildRecentShortcuts(sessions, currentSessionId);
@@ -1,32 +1,26 @@
import React from 'react';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useMessageStore } from '@/stores/messageStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useContextStore } from '@/stores/contextStore';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { getSyncSessionStatus } from '@/sync/sync-refs';
import { useDirectorySync } from '@/sync/sync-context';
type SessionStatusType = 'idle' | 'busy' | 'retry';
const RECENT_ABORT_WINDOW_MS = 2000;
const hasRecentAbort = (sessionId: string): boolean => {
const abortRecord = useSessionStore.getState().sessionAbortFlags.get(sessionId);
const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId);
if (!abortRecord) {
return false;
}
return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS;
};
const setSessionStatus = (sessionId: string, type: SessionStatusType) => {
useSessionStore.setState((state) => {
const next = new Map(state.sessionStatus ?? new Map());
next.set(sessionId, { type });
return { sessionStatus: next };
});
};
const buildQueuedPayload = (queue: QueuedMessage[]) => {
const agents = useConfigStore.getState().getVisibleAgents();
let primaryText = '';
@@ -64,7 +58,7 @@ const buildQueuedPayload = (queue: QueuedMessage[]) => {
const resolveSessionSendConfig = (sessionId: string) => {
const context = useContextStore.getState();
const config = useConfigStore.getState();
const message = useMessageStore.getState();
const selection = useSelectionStore.getState();
const selectedAgent =
context.getSessionAgentSelection(sessionId)
@@ -81,16 +75,17 @@ const resolveSessionSendConfig = (sessionId: string) => {
agentModel?.providerId
?? sessionModel?.providerId
?? config.currentProviderId
?? message.lastUsedProvider?.providerID;
?? selection.lastUsedProvider?.providerID;
const modelID =
agentModel?.modelId
?? sessionModel?.modelId
?? config.currentModelId
?? message.lastUsedProvider?.modelID;
?? selection.lastUsedProvider?.modelID;
const variant =
selectedAgent && providerID && modelID
? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)
? (selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)
?? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID))
: undefined;
return {
@@ -101,10 +96,10 @@ const resolveSessionSendConfig = (sessionId: string) => {
};
};
export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
const enabled = options?.enabled ?? true;
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
const sessionStatus = useSessionStore((state) => state.sessionStatus);
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
@@ -125,7 +120,7 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
return;
}
const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId)?.type ?? 'idle';
const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle';
if (currentStatus !== 'idle') {
return;
}
@@ -135,21 +130,23 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
return;
}
const resolved = resolveSessionSendConfig(sessionId);
// Use send config captured at queue time; fall back to current config
const captured = queueSnapshot[0]?.sendConfig;
const resolved = captured?.providerID && captured?.modelID
? captured
: resolveSessionSendConfig(sessionId);
if (!resolved.providerID || !resolved.modelID) {
return;
}
inFlightSessionsRef.current.add(sessionId);
setSessionStatus(sessionId, 'busy');
try {
await useMessageStore.getState().sendMessage(
await useSessionUIStore.getState().sendMessage(
payload.primaryText,
resolved.providerID,
resolved.modelID,
resolved.agent,
sessionId,
payload.primaryAttachments,
payload.agentMentionName,
payload.additionalParts,
@@ -162,22 +159,23 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
removeFromQueue(sessionId, item.id);
});
} catch (error) {
setSessionStatus(sessionId, 'idle');
console.warn('[queue] queued auto-send failed:', error);
} finally {
inFlightSessionsRef.current.delete(sessionId);
}
};
const statusRecord = sessionStatusRecord ?? {};
const nextStatusMap = new Map(previousStatusRef.current);
const statusEntries = sessionStatus ? Array.from(sessionStatus.entries()) : [];
statusEntries.forEach(([sessionId, status]) => {
nextStatusMap.set(sessionId, status.type);
});
for (const [sessionId, status] of Object.entries(statusRecord)) {
if (status) {
nextStatusMap.set(sessionId, status.type as SessionStatusType);
}
}
const queueEntries = Object.entries(queuedMessages);
queueEntries.forEach(([sessionId, queue]) => {
const currentStatusType = (sessionStatus?.get(sessionId)?.type ?? 'idle') as SessionStatusType;
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
const previousStatusType = previousStatusRef.current.get(sessionId);
const becameIdle =
(previousStatusType === 'busy' || previousStatusType === 'retry')
@@ -192,5 +190,5 @@ export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
});
previousStatusRef.current = nextStatusMap;
}, [enabled, queuedMessages, sessionStatus]);
}, [enabled, queuedMessages, sessionStatusRecord]);
}
+9 -9
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
import type { RouteState, AppRouteState } from '@/lib/router';
@@ -38,7 +38,7 @@ export function useRouter(): void {
const isApplyingRouteRef = React.useRef(false);
// Get store actions (stable references)
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
@@ -58,7 +58,7 @@ export function useRouter(): void {
try {
// 1. Apply session first (may trigger async operations)
if (route.sessionId) {
const currentSessionId = useSessionStore.getState().currentSessionId;
const currentSessionId = useSessionUIStore.getState().currentSessionId;
if (route.sessionId !== currentSessionId) {
await setCurrentSession(route.sessionId);
}
@@ -97,7 +97,7 @@ export function useRouter(): void {
* Get current app state for URL serialization.
*/
const getCurrentAppState = React.useCallback((): AppRouteState => {
const sessionState = useSessionStore.getState();
const sessionState = useSessionUIStore.getState();
const uiState = useUIStore.getState();
return {
@@ -158,9 +158,9 @@ export function useRouter(): void {
return;
}
let prevSessionId: string | null = useSessionStore.getState().currentSessionId;
let prevSessionId: string | null = useSessionUIStore.getState().currentSessionId;
const unsubscribe = useSessionStore.subscribe((state) => {
const unsubscribe = useSessionUIStore.subscribe((state) => {
const sessionId = state.currentSessionId;
// Skip if no change or if we're currently applying a route
@@ -261,7 +261,7 @@ export function navigateToRoute(route: Partial<RouteState>): void {
if (win.__VSCODE_CONFIG__ !== undefined) {
// In VS Code, just apply state changes directly
if (route.sessionId) {
void useSessionStore.getState().setCurrentSession(route.sessionId);
void useSessionUIStore.getState().setCurrentSession(route.sessionId);
}
if (route.settingsPath) {
useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
@@ -300,7 +300,7 @@ export function navigateToRoute(route: Partial<RouteState>): void {
// Also apply to state
if (route.sessionId) {
void useSessionStore.getState().setCurrentSession(route.sessionId);
void useSessionUIStore.getState().setCurrentSession(route.sessionId);
}
if (route.settingsPath) {
useUIStore.getState().setSettingsPage(resolveSettingsSlug(route.settingsPath));
@@ -321,7 +321,7 @@ export function getShareableURL(): string {
return '/';
}
const sessionState = useSessionStore.getState();
const sessionState = useSessionUIStore.getState();
const uiState = useUIStore.getState();
const params = new URLSearchParams();
@@ -1,334 +0,0 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { opencodeClient } from '@/lib/opencode/client';
interface SessionState {
status: 'idle' | 'busy' | 'retry';
lastUpdateAt: number;
metadata?: {
attempt?: number;
message?: string;
next?: number;
};
}
interface SessionAttentionState {
needsAttention: boolean;
lastUserMessageAt: number | null;
lastStatusChangeAt: number;
status: 'idle' | 'busy' | 'retry';
isViewed: boolean;
}
interface ServerSnapshotResponse {
statusSessions: Record<string, SessionState>;
attentionSessions: Record<string, SessionAttentionState>;
serverTime: number;
}
const IMMEDIATE_POLL_DELAY_MS = 150;
const FOLLOW_UP_POLL_DELAY_MS = 1100;
const MIN_IMMEDIATE_POLL_GAP_MS = 1200;
const FOLLOW_UP_REARM_COOLDOWN_MS = 5000;
// Ref to be accessed from outside (e.g., useEventStream) for triggering immediate poll
let triggerImmediatePollRef: (() => void) | null = null;
// Global function to trigger immediate poll from outside React
export const triggerSessionStatusPoll = () => {
if (triggerImmediatePollRef) {
triggerImmediatePollRef();
}
};
/**
* Hook to synchronize session status and attention state from server.
*
* Architecture: server maintains authoritative state, client applies snapshots.
* SSE remains the primary transport; snapshots repair missed updates.
*/
export function useServerSessionStatus(options?: { enabled?: boolean }) {
const enabled = options?.enabled ?? true;
const isSyncingRef = React.useRef(false);
const hasPendingImmediateSyncRef = React.useRef(false);
const lastSyncAtRef = React.useRef(0);
const lastImmediatePollRequestAtRef = React.useRef(0);
const lastFollowUpPollRequestAtRef = React.useRef(0);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const followUpTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const fetchSessionStatus = React.useCallback(async (immediate = false) => {
const now = Date.now();
if (!immediate && now - lastSyncAtRef.current < 1000) {
return;
}
if (immediate && now - lastSyncAtRef.current < 600) {
return;
}
// Prevent concurrent syncs; if an immediate sync is requested while running,
// queue one more pass right after current request settles.
if (isSyncingRef.current) {
if (immediate) {
hasPendingImmediateSyncRef.current = true;
}
return;
}
isSyncingRef.current = true;
lastSyncAtRef.current = now;
try {
const [snapshotResult, upstreamStatusResult] = await Promise.allSettled([
fetch('/api/sessions/snapshot', {
method: 'GET',
cache: 'no-store',
headers: { Accept: 'application/json' },
}).then(async (r) => {
if (!r.ok) {
console.warn('[useServerSessionStatus] API returned', r.status);
if (r.status === 401) {
console.warn('[useServerSessionStatus] Authentication required - session may have expired');
}
throw new Error(String(r.status));
}
return (await r.json()) as ServerSnapshotResponse;
}),
opencodeClient.getGlobalSessionStatus(),
]);
const snapshotData: ServerSnapshotResponse | null =
snapshotResult.status === 'fulfilled' ? snapshotResult.value : null;
const statusSessions = snapshotData?.statusSessions ?? {};
const attentionSessions = snapshotData?.attentionSessions ?? {};
const upstreamStatuses =
upstreamStatusResult.status === 'fulfilled' ? (upstreamStatusResult.value ?? {}) : {};
// Update the session store with server state
const currentStatuses = useSessionStore.getState().sessionStatus || new Map();
let newStatuses: Map<string, { type: 'idle' | 'busy' | 'retry'; confirmedAt?: number; attempt?: number; message?: string; next?: number }> | null = null;
const ensureStatusesMap = () => {
if (!newStatuses) {
newStatuses = new Map(currentStatuses);
}
return newStatuses;
};
for (const [sessionId, state] of Object.entries(statusSessions)) {
const existing = currentStatuses.get(sessionId);
const hasChanged =
!existing ||
existing.type !== state.status ||
existing.attempt !== state.metadata?.attempt ||
existing.message !== state.metadata?.message ||
existing.next !== state.metadata?.next ||
existing.confirmedAt !== state.lastUpdateAt;
// Only update if server state is different
if (hasChanged) {
ensureStatusesMap().set(sessionId, {
type: state.status,
confirmedAt: state.lastUpdateAt,
attempt: state.metadata?.attempt,
message: state.metadata?.message,
next: state.metadata?.next,
});
}
}
// Overlay OpenCode's own session status endpoint.
// This is the source-of-truth for retry message payload and works even when
// OpenChamber server-side tracking misses transient updates.
for (const [sessionId, upstream] of Object.entries(upstreamStatuses)) {
const existing = (newStatuses ?? currentStatuses).get(sessionId);
const hasChanged =
!existing ||
existing.type !== upstream.type ||
existing.attempt !== upstream.attempt ||
existing.message !== upstream.message ||
existing.next !== upstream.next;
if (hasChanged) {
ensureStatusesMap().set(sessionId, {
type: upstream.type,
confirmedAt: Date.now(),
attempt: upstream.attempt,
message: upstream.message,
next: upstream.next,
});
}
}
// Check for sessions that are no longer in server state (treat as idle)
const activeServerStatusIds = new Set(Object.keys(statusSessions));
const activeUpstreamIds = new Set(Object.keys(upstreamStatuses));
for (const [sessionId, currentStatus] of (newStatuses ?? currentStatuses)) {
if ((currentStatus.type === 'busy' || currentStatus.type === 'retry') &&
!activeServerStatusIds.has(sessionId) &&
!activeUpstreamIds.has(sessionId)) {
// Session was busy but not in server state anymore -> mark as idle
ensureStatusesMap().set(sessionId, {
type: 'idle',
confirmedAt: Date.now(),
});
}
}
// Update attention state from server
const currentAttentionStates = useSessionStore.getState().sessionAttentionStates || new Map();
let newAttentionStates: Map<string, SessionAttentionState> | null = null;
const ensureAttentionMap = () => {
if (!newAttentionStates) {
newAttentionStates = new Map(currentAttentionStates);
}
return newAttentionStates;
};
let attentionStatesChanged = false;
for (const [sessionId, attentionState] of Object.entries(attentionSessions)) {
const existing = currentAttentionStates.get(sessionId);
const serverState = attentionState as SessionAttentionState;
const hasChanged =
!existing ||
existing.needsAttention !== serverState.needsAttention ||
existing.lastUserMessageAt !== serverState.lastUserMessageAt ||
existing.lastStatusChangeAt !== serverState.lastStatusChangeAt ||
existing.status !== serverState.status ||
existing.isViewed !== serverState.isViewed;
if (hasChanged) {
ensureAttentionMap().set(sessionId, serverState);
attentionStatesChanged = true;
}
}
// Remove attention states for sessions that no longer exist
for (const sessionId of (newAttentionStates ?? currentAttentionStates).keys()) {
const inStatus = !!statusSessions[sessionId];
const inAttention = !!attentionSessions[sessionId];
if (!inStatus && !inAttention) {
ensureAttentionMap().delete(sessionId);
attentionStatesChanged = true;
}
}
// Only update store if something actually changed
const statusChanged = newStatuses !== null;
if (statusChanged || attentionStatesChanged) {
useSessionStore.setState({
...(statusChanged && newStatuses ? { sessionStatus: newStatuses } : {}),
...(attentionStatesChanged && newAttentionStates ? { sessionAttentionStates: newAttentionStates } : {}),
});
}
if (process.env.NODE_ENV === 'development') {
console.debug('[useServerSessionStatus] Updated session statuses from server:', {
statusCount: Object.keys(statusSessions).length,
upstreamCount: Object.keys(upstreamStatuses).length,
attentionCount: Object.keys(attentionSessions).length,
serverTime: snapshotData?.serverTime,
});
}
} catch (error) {
console.warn('[useServerSessionStatus] Error fetching session status:', error);
} finally {
isSyncingRef.current = false;
if (hasPendingImmediateSyncRef.current) {
hasPendingImmediateSyncRef.current = false;
setTimeout(() => {
void fetchSessionStatus(true);
}, 120);
}
}
}, []);
// Function to trigger immediate snapshot sync from external modules
const triggerImmediatePoll = React.useCallback(() => {
const now = Date.now();
const elapsed = now - lastImmediatePollRequestAtRef.current;
lastImmediatePollRequestAtRef.current = now;
if (!timeoutRef.current) {
const minGapDelay = elapsed >= MIN_IMMEDIATE_POLL_GAP_MS
? IMMEDIATE_POLL_DELAY_MS
: Math.max(IMMEDIATE_POLL_DELAY_MS, MIN_IMMEDIATE_POLL_GAP_MS - elapsed);
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
void fetchSessionStatus(true);
}, minGapDelay);
}
// Run one follow-up sync after short settle period to catch delayed
// server status transitions that happen right after reconnect/restore.
// Re-arm at most once per cooldown window to avoid stacked follow-ups.
if (!followUpTimeoutRef.current && now - lastFollowUpPollRequestAtRef.current >= FOLLOW_UP_REARM_COOLDOWN_MS) {
lastFollowUpPollRequestAtRef.current = now;
followUpTimeoutRef.current = setTimeout(() => {
followUpTimeoutRef.current = null;
void fetchSessionStatus(true);
}, FOLLOW_UP_POLL_DELAY_MS);
}
}, [fetchSessionStatus]);
// Initial snapshot sync on mount
React.useEffect(() => {
if (!enabled) {
return;
}
void fetchSessionStatus(true);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (followUpTimeoutRef.current) {
clearTimeout(followUpTimeoutRef.current);
}
};
}, [enabled, fetchSessionStatus]);
// Sync snapshot when tab becomes visible
React.useEffect(() => {
if (!enabled) {
return;
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
triggerImmediatePoll();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enabled, triggerImmediatePoll]);
// Update the ref for external access
React.useEffect(() => {
if (!enabled) {
triggerImmediatePollRef = null;
return;
}
triggerImmediatePollRef = triggerImmediatePoll;
return () => {
triggerImmediatePollRef = null;
};
}, [enabled, triggerImmediatePoll]);
return {
fetchSessionStatus,
triggerImmediatePoll,
};
}
// Export ref accessor for external modules
export const getTriggerImmediatePoll = () => triggerImmediatePollRef;
export default useServerSessionStatus;
+38 -28
View File
@@ -1,20 +1,14 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionStatus, useSessionMessages, useSessionPermissions } from '@/sync/sync-context';
// Mirrors OpenCode SessionStatus: busy|retry|idle.
export type SessionActivityPhase = 'idle' | 'busy' | 'retry';
export interface SessionActivityResult {
phase: SessionActivityPhase;
isWorking: boolean;
isBusy: boolean;
// Kept for backward compatibility; always false with server session.status.
isCooldown: boolean;
}
@@ -25,33 +19,49 @@ const IDLE_RESULT: SessionActivityResult = {
isCooldown: false,
};
/**
* Determines if a session is actively working.
* Checks session_status and, as a narrow fallback, only the trailing
* assistant message when its completion update has not landed yet.
* Returns idle when permissions are pending (permission indicator takes priority).
*/
export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult {
const phase = useSessionStore((state) => {
if (!sessionId || !state.sessionStatus) {
return 'idle' as SessionActivityPhase;
}
const status = state.sessionStatus.get(sessionId);
return (status?.type ?? 'idle') as SessionActivityPhase;
});
const status = useSessionStatus(sessionId ?? '');
const messages = useSessionMessages(sessionId ?? '');
const permissions = useSessionPermissions(sessionId ?? '');
return React.useMemo<SessionActivityResult>(() => {
if (phase === 'idle') {
return IDLE_RESULT;
}
const isBusy = phase === 'busy';
// No cooldown in server session.status; treat retry as working.
const isCooldown = false;
if (!sessionId) return IDLE_RESULT;
// Permissions pending → idle (permission indicator takes priority)
if (permissions.length > 0) return IDLE_RESULT;
const phase: SessionActivityPhase = (status?.type ?? 'idle') as SessionActivityPhase;
// Only trust the trailing assistant message as a transient fallback while
// waiting for session.status/message.updated to settle.
const lastMessage = messages[messages.length - 1];
const hasPendingAssistant = Boolean(
lastMessage
&& lastMessage.role === 'assistant'
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
);
const statusWorking = phase !== 'idle';
const isWorking = statusWorking || hasPendingAssistant;
if (!isWorking) return IDLE_RESULT;
return {
phase,
isWorking: phase === 'busy' || phase === 'retry',
isBusy,
isCooldown,
phase: statusWorking ? phase : 'busy',
isWorking: true,
isBusy: phase === 'busy' || (!statusWorking && hasPendingAssistant),
isCooldown: false,
};
}, [phase]);
}, [sessionId, status, messages, permissions]);
}
export function useCurrentSessionActivity(): SessionActivityResult {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
return useSessionActivity(currentSessionId);
}
+66 -23
View File
@@ -1,6 +1,9 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionStore } from '@/stores/useSessionStore';
import { opencodeClient } from '@/lib/opencode/client';
import { ensureGlobalSessionsLoaded, useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore';
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -50,8 +53,9 @@ export const buildAutoDeleteCandidates = ({
};
type CleanupResult = {
deletedIds: string[];
completedIds: string[];
failedIds: string[];
action: 'archive' | 'delete';
skippedReason?: 'disabled' | 'loading' | 'cooldown' | 'no-candidates' | 'running';
};
@@ -60,57 +64,65 @@ type CleanupOptions = {
enabled?: boolean;
};
export const useSessionAutoCleanup = (options?: CleanupOptions) => {
export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOptions) => {
const options = typeof enabledOrOptions === 'object' ? enabledOrOptions : undefined;
const autoRun = options?.autoRun !== false;
const enabled = options?.enabled ?? true;
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (options?.enabled ?? true);
const sessions = useSessionStore((state) => state.sessions);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const isLoading = useSessionStore((state) => state.isLoading);
const deleteSessions = useSessionStore((state) => state.deleteSessions);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const isLoading = useSessionUIStore((state) => state.isLoading);
const globalSessions = useGlobalSessionsStore((state) => state.activeSessions);
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt);
const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt);
const [isRunning, setIsRunning] = React.useState(false);
const runningRef = React.useRef(false);
React.useEffect(() => {
void ensureGlobalSessionsLoaded(getAllSyncSessions());
}, []);
const candidates = React.useMemo(() => {
if (autoDeleteAfterDays <= 0) {
return [];
}
return buildAutoDeleteCandidates({
sessions,
sessions: globalSessions,
currentSessionId,
cutoffDays: autoDeleteAfterDays,
});
}, [autoDeleteAfterDays, currentSessionId, sessions]);
}, [autoDeleteAfterDays, currentSessionId, globalSessions]);
const runCleanup = React.useCallback(
async ({ force = false }: { force?: boolean } = {}): Promise<CleanupResult> => {
async ({ force = false }: { force?: boolean } = {}): Promise<CleanupResult> => {
if (runningRef.current) {
return { deletedIds: [], failedIds: [], skippedReason: 'running' };
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'running' };
}
if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) {
if (!force) {
return { deletedIds: [], failedIds: [], skippedReason: 'disabled' };
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'disabled' };
}
}
if (isLoading) {
return { deletedIds: [], failedIds: [], skippedReason: 'loading' };
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'loading' };
}
const now = Date.now();
if (!force && autoDeleteLastRunAt && now - autoDeleteLastRunAt < AUTO_DELETE_INTERVAL_MS) {
return { deletedIds: [], failedIds: [], skippedReason: 'cooldown' };
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'cooldown' };
}
const { activeSessions: sessions } = await ensureGlobalSessionsLoaded(getAllSyncSessions());
if (sessions.length === 0) {
return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' };
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' };
}
const candidateIds = buildAutoDeleteCandidates({
@@ -122,14 +134,44 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
if (candidateIds.length === 0) {
setAutoDeleteLastRunAt(now);
return { deletedIds: [], failedIds: [], skippedReason: 'no-candidates' };
return { completedIds: [], failedIds: [], action: sessionRetentionAction, skippedReason: 'no-candidates' };
}
runningRef.current = true;
setIsRunning(true);
try {
const result = await deleteSessions(candidateIds, { silent: true });
return result;
const sessionMap = new Map(sessions.map((session) => [session.id, session]));
const completedIds: string[] = [];
const failedIds: string[] = [];
for (const id of candidateIds) {
const session = sessionMap.get(id);
const directory = session ? resolveGlobalSessionDirectory(session) : null;
if (!directory) {
failedIds.push(id);
continue;
}
const scopedSdk = opencodeClient.getScopedSdkClient(directory);
try {
if (sessionRetentionAction === 'archive') {
await scopedSdk.session.update({ sessionID: id, directory, time: { archived: Date.now() } });
} else {
await scopedSdk.session.delete({ sessionID: id, directory });
}
completedIds.push(id);
} catch {
failedIds.push(id);
}
}
if (sessionRetentionAction === 'archive') {
useGlobalSessionsStore.getState().archiveSessions(completedIds);
} else {
useGlobalSessionsStore.getState().removeSessions(completedIds);
}
return { completedIds, failedIds, action: sessionRetentionAction };
} finally {
runningRef.current = false;
setIsRunning(false);
@@ -141,9 +183,8 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
autoDeleteEnabled,
autoDeleteLastRunAt,
currentSessionId,
deleteSessions,
isLoading,
sessions,
sessionRetentionAction,
setAutoDeleteLastRunAt,
]
);
@@ -159,7 +200,7 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
if (!autoDeleteEnabled || autoDeleteAfterDays <= 0) {
return;
}
if (isLoading || sessions.length === 0) {
if (isLoading || !hasLoadedGlobalSessions || globalSessions.length === 0) {
return;
}
const now = Date.now();
@@ -173,8 +214,9 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
autoDeleteLastRunAt,
autoRun,
enabled,
hasLoadedGlobalSessions,
globalSessions.length,
isLoading,
sessions.length,
runCleanup,
]);
@@ -183,5 +225,6 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
isRunning,
runCleanup,
keepRecentCount: AUTO_DELETE_KEEP_RECENT,
action: sessionRetentionAction,
};
};
@@ -1,47 +1,8 @@
import React from 'react';
import { opencodeClient } from '@/lib/opencode/client';
import { useSessionStore } from '@/stores/useSessionStore';
type SessionStatusPayload = {
type: 'idle' | 'busy' | 'retry';
attempt?: number;
message?: string;
next?: number;
};
export const useSessionStatusBootstrap = (options?: { enabled?: boolean }) => {
const enabled = options?.enabled ?? true;
React.useEffect(() => {
if (!enabled) {
return;
}
let cancelled = false;
const bootstrap = async () => {
try {
// Use global status to detect busy sessions across all directories,
// including sessions started externally (e.g., via CLI) before UI opened
const statusMap = await opencodeClient.getGlobalSessionStatus();
if (cancelled || !statusMap) return;
const nextStatus = new Map<string, SessionStatusPayload>();
Object.entries(statusMap).forEach(([sessionId, raw]) => {
if (!sessionId || !raw) return;
const status = raw as SessionStatusPayload;
nextStatus.set(sessionId, status);
});
if (nextStatus.size > 0) {
useSessionStore.setState({ sessionStatus: nextStatus });
}
} catch { /* ignored */ }
};
void bootstrap();
return () => {
cancelled = true;
};
}, [enabled]);
/**
* Session status bootstrap is now handled by the sync system's own bootstrap
* (sync/bootstrap.ts). This hook is retained as a no-op for call-site compat.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const useSessionStatusBootstrap = (_options?: { enabled?: boolean }) => {
// no-op — session_status is bootstrapped by sync child stores
};
+114
View File
@@ -0,0 +1,114 @@
import { useState, useRef, useEffect, useMemo } from "react"
type StageConfig = {
/** How many messages to show on first paint */
init: number
/** How many to add per animation frame */
batch: number
}
type UseTimelineStagingInput<T> = {
/** Key that changes when session switches */
sessionKey: string
/** All messages (sorted) */
messages: T[]
/** Config for staging behavior */
config?: StageConfig
}
type UseTimelineStagingResult<T> = {
/** The subset of messages that should be rendered */
stagedMessages: T[]
/** Whether staging is still in progress */
isStaging: boolean
}
const DEFAULT_CONFIG: StageConfig = { init: 1, batch: 3 }
/**
* Defer-mounts small timeline windows so revealing older turns does not
* block first paint with a large DOM mount.
*
* Once staging completes for a session it never re-stages — backfill and
* new messages render immediately.
*
* Defers mounting older turns so first paint isn't blocked by large DOM.
*/
export function useTimelineStaging<T>(
input: UseTimelineStagingInput<T>,
): UseTimelineStagingResult<T> {
const config = input.config ?? DEFAULT_CONFIG
const { sessionKey, messages } = input
const [stagedCount, setStagedCount] = useState(() => messages.length)
const completedSessions = useRef(new Set<string>())
const activeSession = useRef("")
const frameRef = useRef<number | null>(null)
useEffect(() => {
// Cancel any pending animation frame
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
const total = messages.length
// If already completed for this session, show all immediately
if (completedSessions.current.has(sessionKey)) {
setStagedCount(total)
return
}
// Small message list — no staging needed
if (total <= config.init) {
setStagedCount(total)
completedSessions.current.add(sessionKey)
return
}
// Start staging
activeSession.current = sessionKey
let count = Math.min(total, config.init)
setStagedCount(count)
const step = () => {
// Session changed mid-staging — bail
if (activeSession.current !== sessionKey) {
frameRef.current = null
return
}
count = Math.min(messages.length, count + config.batch)
setStagedCount(count)
if (count >= messages.length) {
completedSessions.current.add(sessionKey)
activeSession.current = ""
frameRef.current = null
return
}
frameRef.current = requestAnimationFrame(step)
}
frameRef.current = requestAnimationFrame(step)
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
}
}, [sessionKey, messages.length, config.init, config.batch])
const stagedMessages = useMemo(() => {
if (stagedCount >= messages.length) return messages
return messages.slice(Math.max(0, messages.length - stagedCount))
}, [messages, stagedCount])
const isStaging = activeSession.current === sessionKey &&
!completedSessions.current.has(sessionKey)
return { stagedMessages, isStaging }
}
+17 -20
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords, useSessionPermissions } from '@/sync/sync-context';
import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
/**
@@ -7,45 +8,41 @@ import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
* Call this inside VoiceProvider to enable session awareness during voice.
*/
export function useVoiceContext() {
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const messages = useSessionStore((s) =>
currentSessionId ? s.messages.get(currentSessionId) : undefined
);
const permissions = useSessionStore((s) =>
currentSessionId ? s.permissions.get(currentSessionId) : undefined
);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const messages = useSessionMessageRecords(currentSessionId ?? '');
const permissions = useSessionPermissions(currentSessionId ?? '');
// Track last seen message count to only forward new messages
const lastMessageCountRef = useRef(0);
// Forward new messages to voice agent
useEffect(() => {
if (!currentSessionId || !messages || !isVoiceSessionStarted()) return;
if (!currentSessionId || !messages || messages.length === 0 || !isVoiceSessionStarted()) return;
const currentCount = messages.length;
if (currentCount <= lastMessageCountRef.current) return;
// Get only new messages (messages since last check)
const newMessages = messages.slice(lastMessageCountRef.current);
lastMessageCountRef.current = currentCount;
// Format for voice hooks (extract role and content)
const formattedMessages = newMessages.map(m => ({
role: m.info.role,
content: m.parts.map(p => ('text' in p ? p.text : '')).join('')
content: m.parts.map((p: Record<string, unknown>) => ('text' in p ? p.text : '')).join('')
}));
voiceHooks.onMessages(currentSessionId, formattedMessages);
}, [currentSessionId, messages]);
// Forward permission requests to voice agent
useEffect(() => {
if (!currentSessionId || !permissions || permissions.length === 0) return;
if (!isVoiceSessionStarted()) return;
const request = permissions[0];
if (!request) return;
voiceHooks.onPermissionRequested(
currentSessionId,
request.id,
@@ -53,7 +50,7 @@ export function useVoiceContext() {
request.metadata
);
}, [currentSessionId, permissions]);
// Reset message count when session changes
useEffect(() => {
lastMessageCountRef.current = 0;