From afc717e69e67f53173c30d2230722236f9180d39 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:39:04 +0300 Subject: [PATCH 01/18] feat(chat): derive activity indicator from per-session state and unify provider lifecycle events Replace the chat processing banner with a minimal activity indicator and rebuild the state model underneath it. The old banner was driven by five overlapping pieces of state (isLoading, canAbortSession, claudeStatus in the chat, plus two app-level Sets updated in lockstep through four callbacks) that had to be kept in sync imperatively. Because completion and status events mutated the *viewed* session's flags regardless of which session they belonged to, a background session finishing could hide the indicator for a still-running session, returning to a finished session could briefly show a stale banner, and a late status reply could override a newer request. The fix is structural rather than patch-by-patch: a single Map in useSessionProtection is now the only source of truth for "this session is working". The indicator, stop button, composer streaming state, and session protection are all derived from the viewed session's entry on render, so there is no stale local copy to restore or reset when switching sessions. A PENDING_SESSION_ID sentinel covers the window before a new conversation receives its real session id. Terminal events delete the entry atomically, which is why the indicator disappears the instant the final chunk arrives. Stale check-session-status replies are discarded via an ifStartedBefore guard (an idle reply older than the entry's startedAt describes a previous request, not the current one). The second half unifies the provider lifecycle contract, because the frontend could not be made race-free while each provider terminated differently: - cursor emitted complete twice per run (result line + process close), which double-played the completion sound and let a late close-complete clear a newer request's indicator - aborts produced two completes (the abort-session reply plus the provider's own non-aborted one), so cancelling a run played the celebration sound - codex omitted exitCode; others attached ad-hoc fields (resultText, isError, isNewSession) the client had to know about - claude/codex failures ended with only an error event while gemini/cursor also emit kind:'error' for mid-run stderr noise, so 'error' was ambiguous between "the run died" and "a process wrote to stderr" Every run now ends with exactly one complete built by createCompleteMessage() ({sessionId, actualSessionId, exitCode, success, aborted}); abort-session sends it on behalf of cancelled runs and providers detect the abort and skip their own. error is demoted to an informational row, so stderr noise no longer kills the indicator mid-run, and the client celebrates only success: true completes. Co-Authored-By: Claude Fable 5 --- server/claude-sdk.js | 32 ++++- server/cursor-cli.js | 38 +++-- server/gemini-cli.js | 19 ++- server/modules/websocket/README.md | 7 +- .../services/chat-websocket.service.ts | 20 ++- server/openai-codex.js | 38 +++-- server/opencode-cli.js | 25 ++-- server/shared/utils.ts | 37 +++++ src/components/app/AppContent.tsx | 15 +- .../chat/hooks/useChatComposerState.ts | 58 ++------ .../chat/hooks/useChatRealtimeHandlers.ts | 128 ++++++----------- .../chat/hooks/useChatSessionState.ts | 87 ++++++------ src/components/chat/types/types.ts | 13 +- src/components/chat/view/ChatInterface.tsx | 68 ++++----- .../view/subcomponents/ActivityIndicator.tsx | 80 +++++++++++ .../chat/view/subcomponents/ChatComposer.tsx | 18 +-- .../chat/view/subcomponents/ClaudeStatus.tsx | 130 ------------------ src/components/main-content/types/types.ts | 15 +- .../main-content/view/MainContent.tsx | 8 +- src/hooks/useProjectsState.ts | 4 +- src/hooks/useSessionProtection.ts | 126 +++++++++++------ src/i18n/locales/en/chat.json | 1 + 22 files changed, 487 insertions(+), 480 deletions(-) create mode 100644 src/components/chat/view/subcomponents/ActivityIndicator.tsx delete mode 100644 src/components/chat/view/subcomponents/ClaudeStatus.tsx diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 597e5f47..a0a795c6 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -28,10 +28,14 @@ import { } from './services/notification-orchestrator.js'; import { sessionsService } from './modules/providers/services/sessions.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; -import { createNormalizedMessage } from './shared/utils.js'; +import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); +// Sessions cancelled via abort-session. The abort handler already sent the +// terminal `complete` (aborted: true) to the client, so the run loop must not +// emit a second one when its generator winds down. +const abortedSessionIds = new Set(); const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; @@ -731,14 +735,18 @@ async function queryClaudeSDK(command, options = {}, ws) { // Clean up temporary image files await cleanupTempFiles(tempImagePaths, tempDir); - // Send completion event - ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' })); + // Send the terminal completion event — skipped for aborted runs, whose + // terminal `complete` (aborted: true) was already sent by abort-session. + const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false; + if (!wasAborted) { + ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 })); + } notifyRunStopped({ userId: ws?.userId || null, provider: 'claude', sessionId: capturedSessionId || sessionId || null, sessionName: sessionSummary, - stopReason: 'completed' + stopReason: wasAborted ? 'aborted' : 'completed' }); // Complete @@ -753,14 +761,22 @@ async function queryClaudeSDK(command, options = {}, ws) { // Clean up temporary image files on error await cleanupTempFiles(tempImagePaths, tempDir); + const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false; + if (wasAborted) { + // The abort already produced the terminal complete; a generator throw + // caused by interrupt() is expected noise, not a user-facing error. + return; + } + // Check if Claude CLI is installed for a clearer error message const installed = await providerAuthService.isProviderInstalled('claude'); const errorContent = !installed ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code' : error.message; - // Send error to WebSocket + // Send error to WebSocket, then the terminal complete ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); + ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 })); notifyRunFailed({ userId: ws?.userId || null, provider: 'claude', @@ -787,6 +803,10 @@ async function abortClaudeSDKSession(sessionId) { try { console.log(`Aborting SDK session: ${sessionId}`); + // Mark before interrupting so the run loop knows not to emit its own + // terminal complete (the abort handler sends the aborted one). + abortedSessionIds.add(sessionId); + // Call interrupt() on the query instance await session.instance.interrupt(); @@ -802,6 +822,8 @@ async function abortClaudeSDKSession(sessionId) { return true; } catch (error) { console.error(`Error aborting session ${sessionId}:`, error); + // The run keeps going; let it emit its own terminal complete. + abortedSessionIds.delete(sessionId); return false; } } diff --git a/server/cursor-cli.js b/server/cursor-cli.js index 6cfd7bac..07fa1993 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -4,7 +4,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche import { sessionsService } from './modules/providers/services/sessions.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; -import { createNormalizedMessage } from './shared/utils.js'; +import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -34,6 +34,10 @@ async function spawnCursor(command, options = {}, ws) { let sessionCreatedSent = false; // Track if we've already sent session-created event let hasRetriedWithTrust = false; let settled = false; + // The unified lifecycle contract requires exactly one terminal `complete` + // per run. Cursor surfaces completion twice (the `result` JSON line and + // the process close), so the first emission wins. + let completeSent = false; // Use tools settings passed from frontend, or defaults const settings = toolsSettings || { @@ -197,15 +201,15 @@ async function spawnCursor(command, options = {}, ws) { break; case 'result': { - // Session complete — send stream end + lifecycle complete with result payload - const resultText = typeof response.result === 'string' ? response.result : ''; - ws.send(createNormalizedMessage({ - kind: 'complete', - exitCode: response.subtype === 'success' ? 0 : 1, - resultText, - isError: response.subtype !== 'success', - sessionId: capturedSessionId || sessionId, provider: 'cursor', - })); + // Session complete — terminal lifecycle event for this run + if (!completeSent) { + completeSent = true; + ws.send(createCompleteMessage({ + provider: 'cursor', + sessionId: capturedSessionId || sessionId || null, + exitCode: response.subtype === 'success' ? 0 : 1, + })); + } break; } @@ -271,7 +275,12 @@ async function spawnCursor(command, options = {}, ws) { return; } - ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' })); + // Terminal complete — unless the `result` line already sent it, or the + // run was aborted (abort-session sent the aborted complete). + if (!completeSent && !cursorProcess.aborted) { + completeSent = true; + ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code })); + } if (code === 0) { notifyTerminalState({ code }); @@ -297,6 +306,10 @@ async function spawnCursor(command, options = {}, ws) { : error.message; ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); + if (!completeSent && !cursorProcess.aborted) { + completeSent = true; + ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 })); + } notifyTerminalState({ error }); settleOnce(() => reject(error)); @@ -314,6 +327,9 @@ function abortCursorSession(sessionId) { const process = activeCursorProcesses.get(sessionId); if (process) { console.log(`Aborting Cursor session: ${sessionId}`); + // The abort handler sends the terminal complete (aborted: true); flag the + // process so its close handler does not emit a second one. + process.aborted = true; process.kill('SIGTERM'); activeCursorProcesses.delete(sessionId); return true; diff --git a/server/gemini-cli.js b/server/gemini-cli.js index ee1fd845..72da84d9 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -10,7 +10,7 @@ import GeminiResponseHandler from './gemini-response-handler.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; -import { createNormalizedMessage } from './shared/utils.js'; +import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; // Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -129,6 +129,9 @@ async function spawnGemini(command, options = {}, ws) { let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let assistantBlocks = []; // Accumulate the full response blocks including tools + // Unified lifecycle contract: exactly one terminal `complete` per run + // (close and error handlers can both fire for spawn failures). + let completeSent = false; // Use tools settings passed from frontend, or defaults const settings = toolsSettings || { @@ -486,7 +489,12 @@ async function spawnGemini(command, options = {}, ws) { sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks); } - ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' })); + // Terminal complete — skipped for aborted runs (abort-session + // already sent the aborted complete on this run's behalf). + if (!completeSent && !geminiProcess.aborted) { + completeSent = true; + ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code })); + } // Clean up temporary image files if any if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) { @@ -566,6 +574,10 @@ async function spawnGemini(command, options = {}, ws) { const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' })); + if (!completeSent && !geminiProcess.aborted) { + completeSent = true; + ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 })); + } notifyTerminalState({ error }); reject(error); @@ -590,6 +602,9 @@ function abortGeminiSession(sessionId) { if (geminiProc) { try { + // The abort handler sends the terminal complete (aborted: true); + // flag the process so its close handler does not emit a second one. + geminiProc.aborted = true; geminiProc.kill('SIGTERM'); setTimeout(() => { if (activeGeminiProcesses.has(processKey)) { diff --git a/server/modules/websocket/README.md b/server/modules/websocket/README.md index 12db6349..f3fe7a13 100644 --- a/server/modules/websocket/README.md +++ b/server/modules/websocket/README.md @@ -133,9 +133,10 @@ flowchart TD ### Chat Notes -1. `abort-session` returns a normalized `complete` message with `aborted: true`. -2. `check-session-status` returns `{ type: "session-status", isProcessing }`. -3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`. +1. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`), regardless of provider: `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. Failed runs emit an informational `error` message first, then the terminal `complete` with `success: false`. Mid-run `error` messages (e.g. stderr output) are non-terminal; the frontend only treats `complete` as end-of-run. +2. `abort-session` sends the terminal `complete` (`aborted: true`) on behalf of the cancelled run; providers detect the abort and skip their own `complete` so the client sees exactly one. +3. `check-session-status` returns `{ type: "session-status", isProcessing }`. +4. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`. ## `/shell` Terminal Flow diff --git a/server/modules/websocket/services/chat-websocket.service.ts b/server/modules/websocket/services/chat-websocket.service.ts index 829cb0b0..67833c33 100644 --- a/server/modules/websocket/services/chat-websocket.service.ts +++ b/server/modules/websocket/services/chat-websocket.service.ts @@ -7,7 +7,7 @@ import type { AuthenticatedWebSocketRequest, LLMProvider, } from '@/shared/types.js'; -import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js'; +import { createCompleteMessage, parseIncomingJsonObject } from '@/shared/utils.js'; type ChatIncomingMessage = AnyRecord & { type?: string; @@ -173,14 +173,14 @@ export function handleChatConnection( success = await dependencies.abortClaudeSDKSession(sessionId); } + // Terminal complete on behalf of the cancelled run — providers skip + // their own complete for aborted runs so the client sees exactly one. writer.send( - createNormalizedMessage({ - kind: 'complete', + createCompleteMessage({ + provider, + sessionId, exitCode: success ? 0 : 1, aborted: true, - success, - sessionId, - provider, }) ); return; @@ -202,13 +202,11 @@ export function handleChatConnection( const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; const success = dependencies.abortCursorSession(sessionId); writer.send( - createNormalizedMessage({ - kind: 'complete', + createCompleteMessage({ + provider: 'cursor', + sessionId, exitCode: success ? 0 : 1, aborted: true, - success, - sessionId, - provider: 'cursor', }) ); return; diff --git a/server/openai-codex.js b/server/openai-codex.js index 8e14fcdf..34f5bc05 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -18,7 +18,7 @@ import { notifyRunFailed, notifyRunStopped } from './services/notification-orche import { sessionsService } from './modules/providers/services/sessions.service.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; -import { createNormalizedMessage } from './shared/utils.js'; +import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; // Track active sessions const activeCodexSessions = new Map(); @@ -352,21 +352,26 @@ export async function queryCodex(command, options = {}, ws) { } } - // Send completion event - if (!terminalFailure) { - sendMessage(ws, createNormalizedMessage({ - kind: 'complete', - actualSessionId: capturedSessionId || thread.id || sessionId || null, - sessionId: capturedSessionId || sessionId || null, - provider: 'codex' - })); - notifyRunStopped({ - userId: ws?.userId || null, + // Send the terminal completion event — skipped for aborted runs, whose + // terminal `complete` (aborted: true) was already sent by abort-session. + const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null; + const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted; + if (!runAborted) { + sendMessage(ws, createCompleteMessage({ provider: 'codex', sessionId: capturedSessionId || sessionId || null, - sessionName: sessionSummary, - stopReason: 'completed' - }); + actualSessionId: capturedSessionId || thread.id || sessionId || null, + exitCode: terminalFailure ? 1 : 0, + })); + if (!terminalFailure) { + notifyRunStopped({ + userId: ws?.userId || null, + provider: 'codex', + sessionId: capturedSessionId || sessionId || null, + sessionName: sessionSummary, + stopReason: 'completed' + }); + } } } catch (error) { @@ -386,6 +391,11 @@ export async function queryCodex(command, options = {}, ws) { : error.message; sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' })); + sendMessage(ws, createCompleteMessage({ + provider: 'codex', + sessionId: capturedSessionId || sessionId || null, + exitCode: 1, + })); if (!terminalFailure) { notifyRunFailed({ userId: ws?.userId || null, diff --git a/server/opencode-cli.js b/server/opencode-cli.js index 6f3e0f65..237371ba 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -8,7 +8,7 @@ import { sessionsService } from './modules/providers/services/sessions.service.j import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; -import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js'; +import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js'; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -92,6 +92,9 @@ async function spawnOpenCode(command, options = {}, ws) { let stdoutLineBuffer = ''; let terminalNotificationSent = false; let opencodeProcess = null; + // Unified lifecycle contract: exactly one terminal `complete` per run + // (close and error handlers can both fire for spawn failures). + let completeSent = false; const notifyTerminalState = ({ code = null, error = null } = {}) => { if (terminalNotificationSent) { @@ -256,13 +259,12 @@ async function spawnOpenCode(command, options = {}, ws) { })); } - ws.send(createNormalizedMessage({ - kind: 'complete', - exitCode: code, - isNewSession: !sessionId && !!command, - sessionId: finalSessionId, - provider: 'opencode', - })); + // Terminal complete — skipped for aborted runs (abort-session + // already sent the aborted complete on this run's behalf). + if (!completeSent && !opencodeProcess.aborted) { + completeSent = true; + ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: code })); + } if (code === 0) { notifyTerminalState({ code }); @@ -302,6 +304,10 @@ async function spawnOpenCode(command, options = {}, ws) { sessionId: finalSessionId, provider: 'opencode', })); + if (!completeSent && !opencodeProcess.aborted) { + completeSent = true; + ws.send(createCompleteMessage({ provider: 'opencode', sessionId: finalSessionId, exitCode: 1 })); + } notifyTerminalState({ error }); reject(error); }); @@ -315,6 +321,9 @@ function abortOpenCodeSession(sessionId) { return false; } + // The abort handler sends the terminal complete (aborted: true); flag the + // process so its close handler does not emit a second one. + process.aborted = true; process.kill('SIGTERM'); activeOpenCodeProcesses.delete(sessionId); return true; diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 821489c5..78888ae2 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -346,6 +346,43 @@ export function createNormalizedMessage(fields: NormalizedMessageInput): Normali }; } +/** + * Build the unified terminal `complete` lifecycle message. + * + * Contract: every provider run ends with exactly one `complete` (the + * abort-session handler emits it on behalf of cancelled runs, so aborted runs + * must NOT emit their own). The frontend treats `complete` as the only + * terminal signal and never needs provider-specific handling: + * + * - `sessionId` — the id the client knows this run by ('' if never discovered) + * - `actualSessionId` — canonical id after the run; equals `sessionId` unless + * the provider rewrote it mid-run + * - `exitCode` — 0 on success; a missing/null code (e.g. killed process) + * is reported as failure + * - `success` — exitCode === 0 and not aborted + * - `aborted` — run was cancelled by the user + */ +export function createCompleteMessage(opts: { + provider: NormalizedMessage['provider']; + sessionId?: string | null; + actualSessionId?: string | null; + exitCode?: number | null; + aborted?: boolean; +}): NormalizedMessage { + const exitCode = typeof opts.exitCode === 'number' ? opts.exitCode : 1; + const aborted = Boolean(opts.aborted); + + return createNormalizedMessage({ + kind: 'complete', + provider: opts.provider, + sessionId: opts.sessionId || null, + actualSessionId: opts.actualSessionId || opts.sessionId || null, + exitCode, + success: exitCode === 0 && !aborted, + aborted, + }); +} + // --------------------------- //----------------- MCP CONFIG PARSING UTILITIES ------------ /** diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 1ba41b95..9e5a6ac2 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -28,12 +28,9 @@ function AppContentInner() { const wasConnectedRef = useRef(false); const { - activeSessions, processingSessions, - markSessionAsActive, - markSessionAsInactive, - markSessionAsProcessing, - markSessionAsNotProcessing, + markSessionProcessing, + markSessionIdle, } = useSessionProtection(); const { @@ -57,7 +54,7 @@ function AppContentInner() { navigate, latestMessage, isMobile, - activeSessions, + activeSessions: processingSessions, }); usePaletteOpsRegister({ @@ -185,10 +182,8 @@ function AppContentInner() { onMenuClick={() => setSidebarOpen(true)} isLoading={isLoadingProjects} onInputFocusChange={setIsInputFocused} - onSessionActive={markSessionAsActive} - onSessionInactive={markSessionAsInactive} - onSessionProcessing={markSessionAsProcessing} - onSessionNotProcessing={markSessionAsNotProcessing} + onSessionProcessing={markSessionProcessing} + onSessionIdle={markSessionIdle} processingSessions={processingSessions} onNavigateToSession={(targetSessionId: string, options) => navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) }) diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index dca2b2f8..81cd97e4 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -12,6 +12,8 @@ import type { import { useDropzone } from 'react-dropzone'; import { authenticatedFetch } from '../../../utils/api'; +import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection'; +import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import { grantClaudeToolPermission } from '../utils/chatPermissions'; import { safeLocalStorage } from '../utils/chatStorage'; import type { @@ -25,10 +27,6 @@ import { escapeRegExp } from '../utils/chatFormatting'; import { useFileMentions } from './useFileMentions'; import { type SlashCommand, useSlashCommands } from './useSlashCommands'; -type PendingViewSession = { - startedAt: number; -}; - interface UseChatComposerStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; @@ -46,17 +44,12 @@ interface UseChatComposerStateArgs { tokenBudget: Record | null; sendMessage: (message: unknown) => void; sendByCtrlEnter?: boolean; - onSessionActive?: (sessionId?: string | null) => void; - onSessionProcessing?: (sessionId?: string | null) => void; + onSessionProcessing?: MarkSessionProcessing; onInputFocusChange?: (focused: boolean) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; - pendingViewSessionRef: { current: PendingViewSession | null }; scrollToBottom: () => void; addMessage: (msg: ChatMessage) => void; - setIsLoading: (loading: boolean) => void; - setCanAbortSession: (canAbort: boolean) => void; - setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch>; } @@ -177,17 +170,12 @@ export function useChatComposerState({ tokenBudget, sendMessage, sendByCtrlEnter, - onSessionActive, onSessionProcessing, onInputFocusChange, onFileOpen, onShowSettings, - pendingViewSessionRef, scrollToBottom, addMessage, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setIsUserScrolledUp, setPendingPermissionRequests, }: UseChatComposerStateArgs) { @@ -620,27 +608,18 @@ export function useChatComposerState({ }; addMessage(userMessage); - setIsLoading(true); // Processing banner starts - setCanAbortSession(true); - setClaudeStatus({ - text: 'Processing', - tokens: 0, - can_interrupt: true, + // Mark this request as processing in the per-session activity map (the + // single source of truth the indicator derives from). A brand-new + // conversation has no session id yet, so it is tracked under the + // pending placeholder until `session_created` announces the real id. + onSessionProcessing?.(effectiveSessionId || PENDING_SESSION_ID, { + statusText: null, + canInterrupt: true, }); setIsUserScrolledUp(false); setTimeout(() => scrollToBottom(), 100); - if (!effectiveSessionId && !selectedSession?.id) { - // This tracks only that a request is in flight before the provider has - // emitted its real session id; routing still waits for session_created. - pendingViewSessionRef.current = { startedAt: Date.now() }; - } - if (effectiveSessionId) { - onSessionActive?.(effectiveSessionId); - onSessionProcessing?.(effectiveSessionId); - } - const getToolsSettings = () => { try { const settingsKey = @@ -776,19 +755,14 @@ export function useChatComposerState({ geminiModel, opencodeModel, isLoading, - onSessionActive, onSessionProcessing, - pendingViewSessionRef, permissionMode, provider, resetCommandMenuState, scrollToBottom, selectedProject, sendMessage, - setCanAbortSession, addMessage, - setClaudeStatus, - setIsLoading, setIsUserScrolledUp, slashCommands, ], @@ -1000,15 +974,11 @@ export function useChatComposerState({ }); }); - setPendingPermissionRequests((previous) => { - const next = previous.filter((request) => !validIds.includes(request.requestId)); - if (next.length === 0) { - setClaudeStatus(null); - } - return next; - }); + setPendingPermissionRequests((previous) => + previous.filter((request) => !validIds.includes(request.requestId)), + ); }, - [sendMessage, setClaudeStatus, setPendingPermissionRequests], + [sendMessage, setPendingPermissionRequests], ); const [isInputFocused, setIsInputFocused] = useState(false); diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index c2683933..25e10029 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -4,14 +4,12 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification'; import { playChatCompletionSound } from '../../../utils/notificationSound'; +import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection'; +import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types'; import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; -type PendingViewSession = { - startedAt: number; -}; - type LatestChatMessage = { type?: string; kind?: string; @@ -55,18 +53,14 @@ interface UseChatRealtimeHandlersArgs { selectedSession: ProjectSession | null; currentSessionId: string | null; setCurrentSessionId: (sessionId: string | null) => void; - setIsLoading: (loading: boolean) => void; - setCanAbortSession: (canAbort: boolean) => void; - setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void; setTokenBudget: (budget: Record | null) => void; setPendingPermissionRequests: Dispatch>; - pendingViewSessionRef: MutableRefObject; streamTimerRef: MutableRefObject; accumulatedStreamRef: MutableRefObject; - onSessionInactive?: (sessionId?: string | null) => void; - onSessionActive?: (sessionId?: string | null) => void; - onSessionProcessing?: (sessionId?: string | null) => void; - onSessionNotProcessing?: (sessionId?: string | null) => void; + /** When each session's `check-session-status` was last sent; guards stale idle replies. */ + statusCheckSentAtRef: MutableRefObject>; + onSessionProcessing?: MarkSessionProcessing; + onSessionIdle?: MarkSessionIdle; onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void; onWebSocketReconnect?: () => void; sessionStore: SessionStore; @@ -82,18 +76,13 @@ export function useChatRealtimeHandlers({ selectedSession, currentSessionId, setCurrentSessionId, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setTokenBudget, setPendingPermissionRequests, - pendingViewSessionRef, streamTimerRef, accumulatedStreamRef, - onSessionInactive, - onSessionActive, + statusCheckSentAtRef, onSessionProcessing, - onSessionNotProcessing, + onSessionIdle, onNavigateToSession, onWebSocketReconnect, sessionStore, @@ -138,35 +127,24 @@ export function useChatRealtimeHandlers({ const status = msg.status; if (status) { - const statusInfo = { - text: status.text || 'Working...', - tokens: status.tokens || 0, - can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true, - }; - setClaudeStatus(statusInfo); - setIsLoading(true); - setCanAbortSession(statusInfo.can_interrupt); + onSessionProcessing?.(statusSessionId, { + statusText: status.text || null, + canInterrupt: status.can_interrupt !== false, + }); return; } - // Legacy isProcessing format from check-session-status - const isCurrentSession = - statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); - + // Reply to check-session-status (or unsolicited processing update) if (msg.isProcessing) { - onSessionActive?.(statusSessionId); onSessionProcessing?.(statusSessionId); - if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); } return; } - onSessionInactive?.(statusSessionId); - onSessionNotProcessing?.(statusSessionId); - if (isCurrentSession) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } + // Idle reply: ignore it if a newer request started after the check + // was sent — the reply describes the older request. + onSessionIdle?.(statusSessionId, { + ifStartedBefore: statusCheckSentAtRef.current.get(statusSessionId), + }); return; } @@ -238,23 +216,15 @@ export function useChatRealtimeHandlers({ // We no longer synthesize client-side placeholder IDs. Until the provider // announces `session_created`, the active id is expected to be null. if (!currentSessionId) { - console.log('Session created with ID:', newSessionId); - console.log('Existing session ID:', currentSessionId); setCurrentSessionId(newSessionId); setPendingPermissionRequests((prev) => prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })), ); } - pendingViewSessionRef.current = null; - onSessionActive?.(newSessionId); + // The in-flight request now has a concrete session id: migrate the + // processing entry from the pending placeholder. + onSessionIdle?.(PENDING_SESSION_ID); onSessionProcessing?.(newSessionId); - setIsLoading(true); - setCanAbortSession(true); - setClaudeStatus({ - text: 'Processing', - tokens: 0, - can_interrupt: true, - }); onNavigateToSession?.(newSessionId); break; } @@ -271,24 +241,27 @@ export function useChatRealtimeHandlers({ } accumulatedStreamRef.current = ''; - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); + // `complete` is the unified terminal event — every provider run ends + // with exactly one, regardless of success, failure, or abort. The + // indicator derives from the processing map, so deleting the entry + // hides it immediately and atomically. + onSessionIdle?.(sid); + onSessionIdle?.(PENDING_SESSION_ID); setPendingPermissionRequests([]); - onSessionInactive?.(sid); - onSessionNotProcessing?.(sid); - pendingViewSessionRef.current = null; // Handle aborted case if (msg.aborted) { // Abort was requested — the complete event confirms it - // No special UI action needed beyond clearing loading state above + // No special UI action needed beyond clearing the processing entry above // The backend already sent any abort-related messages break; } - showCompletionTitleIndicator(); - void playChatCompletionSound(); + // Celebrate only successful runs (failed runs end with success: false). + if (msg.success !== false) { + showCompletionTitleIndicator(); + void playChatCompletionSound(); + } const actualSessionId = typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0 @@ -302,6 +275,7 @@ export function useChatRealtimeHandlers({ if (actualSessionId && sid && actualSessionId !== sid) { sessionStore.replaceSessionId(sid, actualSessionId); + onSessionIdle?.(actualSessionId); if (isVisibleSession) { setCurrentSessionId(actualSessionId); @@ -317,15 +291,9 @@ export function useChatRealtimeHandlers({ break; } - case 'error': { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - onSessionInactive?.(sid); - onSessionNotProcessing?.(sid); - pendingViewSessionRef.current = null; - break; - } + // 'error' is an informational message row, not a terminal event — + // providers emit it for mid-run stderr output too. Run teardown is + // always signalled by the unified 'complete' that follows. case 'permission_request': { if (!msg.requestId) break; @@ -340,9 +308,7 @@ export function useChatRealtimeHandlers({ receivedAt: new Date(), }]; }); - setIsLoading(true); - setCanAbortSession(true); - setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true }); + onSessionProcessing?.(sid || PENDING_SESSION_ID); break; } @@ -357,13 +323,10 @@ export function useChatRealtimeHandlers({ if (msg.text === 'token_budget' && msg.tokenBudget) { setTokenBudget(msg.tokenBudget as Record); } else if (msg.text) { - setClaudeStatus({ - text: msg.text, - tokens: msg.tokens || 0, - can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true, + onSessionProcessing?.(sid || PENDING_SESSION_ID, { + statusText: msg.text, + canInterrupt: msg.canInterrupt !== false, }); - setIsLoading(true); - setCanAbortSession(msg.canInterrupt !== false); } break; } @@ -379,18 +342,13 @@ export function useChatRealtimeHandlers({ selectedSession, currentSessionId, setCurrentSessionId, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setTokenBudget, setPendingPermissionRequests, - pendingViewSessionRef, streamTimerRef, accumulatedStreamRef, - onSessionInactive, - onSessionActive, + statusCheckSentAtRef, onSessionProcessing, - onSessionNotProcessing, + onSessionIdle, onNavigateToSession, onWebSocketReconnect, sessionStore, diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index d11ff3cb..69268f61 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import type { MutableRefObject } from 'react'; import { authenticatedFetch } from '../../../utils/api'; +import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection'; +import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { ChatMessage, Provider } from '../types/types'; @@ -12,10 +14,6 @@ import { normalizedToChatMessages } from './useChatMessages'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; -type PendingViewSession = { - startedAt: number; -}; - interface UseChatSessionStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; @@ -24,9 +22,11 @@ interface UseChatSessionStateArgs { autoScrollToBottom?: boolean; externalMessageUpdate?: number; newSessionTrigger?: number; - processingSessions?: Set; + processingSessions?: SessionActivityMap; + onSessionIdle?: MarkSessionIdle; resetStreamingState: () => void; - pendingViewSessionRef: MutableRefObject; + /** When each session's `check-session-status` was last sent; guards stale idle replies. */ + statusCheckSentAtRef: MutableRefObject>; sessionStore: SessionStore; } @@ -99,21 +99,19 @@ export function useChatSessionState({ externalMessageUpdate, newSessionTrigger, processingSessions, + onSessionIdle, resetStreamingState, - pendingViewSessionRef, + statusCheckSentAtRef, sessionStore, }: UseChatSessionStateArgs) { - const [isLoading, setIsLoading] = useState(false); const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false); const [totalMessages, setTotalMessages] = useState(0); - const [canAbortSession, setCanAbortSession] = useState(false); const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const [tokenBudget, setTokenBudget] = useState | null>(null); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES); - const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null); const [allMessagesLoaded, setAllMessagesLoaded] = useState(false); const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false); const [loadAllJustFinished, setLoadAllJustFinished] = useState(false); @@ -170,10 +168,7 @@ export function useChatSessionState({ * - No coupling to unrelated external update signals. */ resetStreamingState(); - pendingViewSessionRef.current = null; - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); + onSessionIdle?.(PENDING_SESSION_ID); setCurrentSessionId(null); setPendingUserMessage(null); sessionStorage.removeItem('cursorSessionId'); @@ -204,13 +199,29 @@ export function useChatSessionState({ clearTimeout(loadAllFinishedTimerRef.current); loadAllFinishedTimerRef.current = null; } - }, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]); + }, [newSessionTrigger, onSessionIdle, resetStreamingState]); + + /* ---------------------------------------------------------------- */ + /* Derive processing state for the viewed session */ + /* ---------------------------------------------------------------- */ + + const activeSessionId = selectedSession?.id || currentSessionId || null; + + // The activity indicator always reflects the latest status of the session + // being viewed (or of the pending not-yet-created session on a fresh + // draft) — never stale local UI state from the last time it was open. + const sessionActivity = processingSessions?.get(activeSessionId ?? PENDING_SESSION_ID) ?? null; + const isProcessing = sessionActivity !== null; + const canAbortSession = isProcessing && sessionActivity.canInterrupt; + + // Ref mirror so effects can read the latest map without re-running on + // every activity transition. + const processingSessionsRef = useRef(processingSessions); + processingSessionsRef.current = processingSessions; /* ---------------------------------------------------------------- */ /* Derive chatMessages from the store */ /* ---------------------------------------------------------------- */ - - const activeSessionId = selectedSession?.id || currentSessionId || null; const [pendingUserMessage, setPendingUserMessage] = useState(null); const flushedPendingUserMessageRef = useRef(null); @@ -430,16 +441,12 @@ export function useChatSessionState({ useEffect(() => { if (!selectedSession || !selectedProject) { // A new provider run can be in flight before the router has a canonical - // selectedSession. Keep the processing banner alive until complete/error. - if (pendingViewSessionRef.current) { + // selectedSession. Keep the draft view intact until complete/error. + if (processingSessionsRef.current?.has(PENDING_SESSION_ID)) { return; } resetStreamingState(); - pendingViewSessionRef.current = null; - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); setCurrentSessionId(null); sessionStorage.removeItem('cursorSessionId'); messagesOffsetRef.current = 0; @@ -461,9 +468,6 @@ export function useChatSessionState({ const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; if (sessionChanged) { resetStreamingState(); - pendingViewSessionRef.current = null; - setClaudeStatus(null); - setCanAbortSession(false); } // Reset pagination/scroll state @@ -482,7 +486,6 @@ export function useChatSessionState({ if (sessionChanged) { setTokenBudget(null); - setIsLoading(false); } setCurrentSessionId(selectedSession.id); @@ -490,8 +493,11 @@ export function useChatSessionState({ sessionStorage.setItem('cursorSessionId', selectedSession.id); } - // Check session status + // Reconcile processing state with the server. Recording the send time + // lets the reply handler discard idle replies that a newer request has + // since outdated. if (ws) { + statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider }); } @@ -516,11 +522,11 @@ export function useChatSessionState({ setIsLoadingSessionMessages(false); }); }, [ - pendingViewSessionRef, resetStreamingState, selectedProject, selectedSession?.id, sendMessage, + statusCheckSentAtRef, ws, sessionStore, ]); @@ -534,7 +540,7 @@ export function useChatSessionState({ const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude'; // Skip store refresh during active streaming - if (!isLoading) { + if (!isProcessing) { await sessionStore.refreshFromServer(selectedSession.id, { provider: (selectedSession.__provider || provider) as LLMProvider, projectId: selectedProject.projectId, @@ -559,7 +565,7 @@ export function useChatSessionState({ selectedProject, selectedSession, sessionStore, - isLoading, + isProcessing, ]); // Search navigation target @@ -726,16 +732,6 @@ export function useChatSessionState({ return () => container.removeEventListener('scroll', handleScroll); }, [handleScroll]); - useEffect(() => { - const activeViewSessionId = selectedSession?.id || currentSessionId; - if (!activeViewSessionId || !processingSessions) return; - const shouldBeProcessing = processingSessions.has(activeViewSessionId); - if (shouldBeProcessing && !isLoading) { - setIsLoading(true); - setCanAbortSession(true); - } - }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]); - // "Load all" overlay const prevLoadingRef = useRef(false); useEffect(() => { @@ -817,16 +813,15 @@ export function useChatSessionState({ addMessage, clearMessages, rewindMessages, - isLoading, - setIsLoading, + sessionActivity, + isProcessing, + canAbortSession, currentSessionId, setCurrentSessionId, isLoadingSessionMessages, isLoadingMoreMessages, hasMoreMessages, totalMessages, - canAbortSession, - setCanAbortSession, isUserScrolledUp, setIsUserScrolledUp, tokenBudget, @@ -839,8 +834,6 @@ export function useChatSessionState({ isLoadingAllMessages, loadAllJustFinished, showLoadAllOverlay, - claudeStatus, - setClaudeStatus, createDiff, scrollContainerRef, scrollToBottom, diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 474f23e1..bfede588 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -1,4 +1,9 @@ import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; +import type { + MarkSessionIdle, + MarkSessionProcessing, + SessionActivityMap, +} from '../../../hooks/useSessionProtection'; export type Provider = LLMProvider; @@ -110,11 +115,9 @@ export interface ChatInterfaceProps { latestMessage: any; onFileOpen?: (filePath: string, diffInfo?: any) => void; onInputFocusChange?: (focused: boolean) => void; - onSessionActive?: (sessionId?: string | null) => void; - onSessionInactive?: (sessionId?: string | null) => void; - onSessionProcessing?: (sessionId?: string | null) => void; - onSessionNotProcessing?: (sessionId?: string | null) => void; - processingSessions?: Set; + onSessionProcessing?: MarkSessionProcessing; + onSessionIdle?: MarkSessionIdle; + processingSessions?: SessionActivityMap; onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void; onShowSettings?: () => void; autoExpandTools?: boolean; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 01ecb68a..c4b46391 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -17,10 +17,6 @@ import ChatComposer from './subcomponents/ChatComposer'; import CommandResultModal from './subcomponents/CommandResultModal'; -type PendingViewSession = { - startedAt: number; -}; - function ChatInterface({ selectedProject, selectedSession, @@ -29,10 +25,8 @@ function ChatInterface({ latestMessage, onFileOpen, onInputFocusChange, - onSessionActive, - onSessionInactive, onSessionProcessing, - onSessionNotProcessing, + onSessionIdle, processingSessions, onNavigateToSession, onShowSettings, @@ -51,7 +45,9 @@ function ChatInterface({ const sessionStore = useSessionStore(); const streamTimerRef = useRef(null); const accumulatedStreamRef = useRef(''); - const pendingViewSessionRef = useRef(null); + // When each session's `check-session-status` was last sent; idle replies + // older than a later local request are discarded as stale. + const statusCheckSentAtRef = useRef(new Map()); const resetStreamingState = useCallback(() => { if (streamTimerRef.current) { @@ -92,16 +88,15 @@ function ChatInterface({ const { chatMessages, addMessage, - isLoading, - setIsLoading, + sessionActivity, + isProcessing, + canAbortSession, currentSessionId, setCurrentSessionId, isLoadingSessionMessages, isLoadingMoreMessages, hasMoreMessages, totalMessages, - canAbortSession, - setCanAbortSession, isUserScrolledUp, setIsUserScrolledUp, tokenBudget, @@ -114,8 +109,6 @@ function ChatInterface({ isLoadingAllMessages, loadAllJustFinished, showLoadAllOverlay, - claudeStatus, - setClaudeStatus, createDiff, scrollContainerRef, scrollToBottom, @@ -130,8 +123,9 @@ function ChatInterface({ externalMessageUpdate, newSessionTrigger, processingSessions, + onSessionIdle, resetStreamingState, - pendingViewSessionRef, + statusCheckSentAtRef, sessionStore, }); @@ -191,40 +185,40 @@ function ChatInterface({ codexModel, geminiModel, opencodeModel, - isLoading, + isLoading: isProcessing, canAbortSession, tokenBudget, sendMessage, sendByCtrlEnter, - onSessionActive, onSessionProcessing, onInputFocusChange, onFileOpen, onShowSettings, - pendingViewSessionRef, scrollToBottom, addMessage, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setIsUserScrolledUp, setPendingPermissionRequests, }); - // On WebSocket reconnect, re-fetch the current session's messages from the server - // so missed streaming events are shown. Also reset isLoading. + // On WebSocket reconnect, re-fetch the current session's messages from the + // server so missed streaming events are shown, then re-check the session's + // processing status — the authoritative reply restores or clears the + // activity indicator depending on whether the run is still active. const handleWebSocketReconnect = useCallback(async () => { if (!selectedProject || !selectedSession) return; - const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; + const providerVal = + selectedSession.__provider + || (localStorage.getItem('selected-provider') as LLMProvider) + || 'claude'; await sessionStore.refreshFromServer(selectedSession.id, { - provider: (selectedSession.__provider || providerVal) as LLMProvider, + provider: providerVal as LLMProvider, // Use DB projectId; legacy folder-derived projectName is no longer accepted here. projectId: selectedProject.projectId, projectPath: selectedProject.fullPath || selectedProject.path || '', }); - setIsLoading(false); - setCanAbortSession(false); - }, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]); + statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); + sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider: providerVal }); + }, [selectedProject, selectedSession, sendMessage, sessionStore]); useChatRealtimeHandlers({ latestMessage, @@ -232,25 +226,20 @@ function ChatInterface({ selectedSession, currentSessionId, setCurrentSessionId, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setTokenBudget, setPendingPermissionRequests, - pendingViewSessionRef, streamTimerRef, accumulatedStreamRef, - onSessionInactive, - onSessionActive, + statusCheckSentAtRef, onSessionProcessing, - onSessionNotProcessing, + onSessionIdle, onNavigateToSession, onWebSocketReconnect: handleWebSocketReconnect, sessionStore, }); useEffect(() => { - if (!isLoading || !canAbortSession) { + if (!canAbortSession) { return; } @@ -267,7 +256,7 @@ function ChatInterface({ return () => { document.removeEventListener('keydown', handleGlobalEscape, { capture: true }); }; - }, [canAbortSession, handleAbortSession, isLoading]); + }, [canAbortSession, handleAbortSession]); useEffect(() => { return () => { @@ -362,10 +351,9 @@ function ChatInterface({ pendingPermissionRequests={pendingPermissionRequests} handlePermissionDecision={handlePermissionDecision} handleGrantToolPermission={handleGrantToolPermission} - claudeStatus={claudeStatus} - isLoading={isLoading} + activity={sessionActivity} + isLoading={isProcessing} onAbortSession={handleAbortSession} - provider={provider} permissionMode={permissionMode} onModeSwitch={cyclePermissionMode} tokenBudget={tokenBudget} diff --git a/src/components/chat/view/subcomponents/ActivityIndicator.tsx b/src/components/chat/view/subcomponents/ActivityIndicator.tsx new file mode 100644 index 00000000..afb30af4 --- /dev/null +++ b/src/components/chat/view/subcomponents/ActivityIndicator.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Shimmer } from '../../../../shared/view/ui'; +import type { SessionActivity } from '../../../../hooks/useSessionProtection'; + +type ActivityIndicatorProps = { + activity: SessionActivity | null; + onAbort?: () => void; +}; + +const ACTION_KEYS = [ + 'claudeStatus.actions.thinking', + 'claudeStatus.actions.processing', + 'claudeStatus.actions.analyzing', + 'claudeStatus.actions.working', + 'claudeStatus.actions.computing', + 'claudeStatus.actions.reasoning', +]; +const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; + +/** + * Minimal response-in-progress indicator, in the spirit of the inline status + * lines in Claude Code / Codex / OpenCode: a shimmering activity label, the + * elapsed time, and an interrupt affordance. Rendered only while the viewed + * session has an entry in the processing map; it disappears the instant that + * entry is removed. + */ +export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) { + const { t } = useTranslation('chat'); + const startedAt = activity?.startedAt ?? null; + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + useEffect(() => { + if (startedAt === null) return; + const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))); + update(); + const timer = setInterval(update, 1000); + return () => clearInterval(timer); + }, [startedAt]); + + if (!activity) return null; + + const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] })); + const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length]) + .replace(/\.+$/, ''); + + const minutes = Math.floor(elapsedSeconds / 60); + const seconds = elapsedSeconds % 60; + const elapsedLabel = minutes < 1 + ? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' }) + : t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' }); + + return ( +
+
+ + {`${label}…`} + {elapsedLabel} + + {activity.canInterrupt && onAbort && ( + + )} +
+
+ ); +} diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 4812078b..c60aa893 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -11,7 +11,8 @@ import type { } from 'react'; import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react'; -import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types'; +import type { SessionActivity } from '../../../../hooks/useSessionProtection'; +import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; import { PromptInput, PromptInputHeader, @@ -24,7 +25,7 @@ import { } from '../../../../shared/view/ui'; import CommandMenu from './CommandMenu'; -import ClaudeStatus from './ClaudeStatus'; +import ActivityIndicator from './ActivityIndicator'; import ImageAttachment from './ImageAttachment'; import PermissionRequestsBanner from './PermissionRequestsBanner'; import TokenUsageSummary from './TokenUsageSummary'; @@ -51,10 +52,9 @@ interface ChatComposerProps { decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown }, ) => void; handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean }; - claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null; + activity: SessionActivity | null; isLoading: boolean; onAbortSession: () => void; - provider: Provider | string; permissionMode: PermissionMode | string; onModeSwitch: () => void; tokenBudget: Record | null; @@ -105,10 +105,9 @@ export default function ChatComposer({ pendingPermissionRequests, handlePermissionDecision, handleGrantToolPermission, - claudeStatus, + activity, isLoading, onAbortSession, - provider, permissionMode, onModeSwitch, tokenBudget, @@ -173,12 +172,7 @@ export default function ChatComposer({ return (
{!hasPendingPermissions && ( - + )} {pendingPermissionRequests.length > 0 && ( diff --git a/src/components/chat/view/subcomponents/ClaudeStatus.tsx b/src/components/chat/view/subcomponents/ClaudeStatus.tsx deleted file mode 100644 index 4640ffbc..00000000 --- a/src/components/chat/view/subcomponents/ClaudeStatus.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { cn } from '../../../../lib/utils'; -import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; - -type ClaudeStatusProps = { - status: { - text?: string; - tokens?: number; - can_interrupt?: boolean; - } | null; - onAbort?: () => void; - isLoading: boolean; - provider?: string; -}; - -const ACTION_KEYS = [ - 'claudeStatus.actions.thinking', - 'claudeStatus.actions.processing', - 'claudeStatus.actions.analyzing', - 'claudeStatus.actions.working', - 'claudeStatus.actions.computing', - 'claudeStatus.actions.reasoning', -]; -const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; - -const PROVIDER_LABEL_KEYS: Record = { - claude: 'messageTypes.claude', - codex: 'messageTypes.codex', - cursor: 'messageTypes.cursor', - gemini: 'messageTypes.gemini', - opencode: 'messageTypes.opencode', -}; - -function formatElapsedTime(totalSeconds: number) { - const mins = Math.floor(totalSeconds / 60); - const secs = totalSeconds % 60; - return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`; -} - -export default function ClaudeStatus({ - status, - onAbort, - isLoading, - provider = 'claude', -}: ClaudeStatusProps) { - const { t } = useTranslation('chat'); - const [elapsedTime, setElapsedTime] = useState(0); - const [dots, setDots] = useState(''); - - useEffect(() => { - if (!isLoading) { - setElapsedTime(0); - return; - } - const startTime = Date.now(); - const timer = setInterval(() => { - setElapsedTime(Math.floor((Date.now() - startTime) / 1000)); - }, 1000); - const dotTimer = setInterval(() => { - setDots((prev) => (prev.length >= 3 ? '' : prev + '.')); - }, 500); - - return () => { - clearInterval(timer); - clearInterval(dotTimer); - }; - }, [isLoading]); - - if (!isLoading && !status) return null; - - const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] })); - const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, ''); - - const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' }); - - return ( -
-
- - {/* Left Side: Identity & Status */} -
-
- - {isLoading && ( - - )} -
- -
- - {providerLabel} - -
- -

- {statusText}{isLoading ? dots : ''} -

-
-
-
- - {/* Right Side: Metrics & Actions */} -
- {isLoading && status?.can_interrupt !== false && onAbort && ( - <> -
- {formatElapsedTime(elapsedTime)} -
- - - - )} -
-
-
- ); -} diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index 68b03b29..e04d3bd5 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -1,10 +1,13 @@ import type { Dispatch, SetStateAction } from 'react'; import type { AppTab, Project, ProjectSession } from '../../../types/app'; +import type { + MarkSessionIdle, + MarkSessionProcessing, + SessionActivityMap, +} from '../../../hooks/useSessionProtection'; import type { SessionNavigationOptions } from '../../chat/types/types'; -export type SessionLifecycleHandler = (sessionId?: string | null) => void; - export type TaskMasterTask = { id: string | number; title?: string; @@ -46,11 +49,9 @@ export type MainContentProps = { onMenuClick: () => void; isLoading: boolean; onInputFocusChange: (focused: boolean) => void; - onSessionActive: SessionLifecycleHandler; - onSessionInactive: SessionLifecycleHandler; - onSessionProcessing: SessionLifecycleHandler; - onSessionNotProcessing: SessionLifecycleHandler; - processingSessions: Set; + onSessionProcessing: MarkSessionProcessing; + onSessionIdle: MarkSessionIdle; + processingSessions: SessionActivityMap; onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void; onShowSettings: () => void; externalMessageUpdate: number; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index f0a29a70..2db3b583 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -42,10 +42,8 @@ function MainContent({ onMenuClick, isLoading, onInputFocusChange, - onSessionActive, - onSessionInactive, onSessionProcessing, - onSessionNotProcessing, + onSessionIdle, processingSessions, onNavigateToSession, onShowSettings, @@ -131,10 +129,8 @@ function MainContent({ latestMessage={latestMessage} onFileOpen={handleFileOpen} onInputFocusChange={onInputFocusChange} - onSessionActive={onSessionActive} - onSessionInactive={onSessionInactive} onSessionProcessing={onSessionProcessing} - onSessionNotProcessing={onSessionNotProcessing} + onSessionIdle={onSessionIdle} processingSessions={processingSessions} onNavigateToSession={onNavigateToSession} onShowSettings={onShowSettings} diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index e6005693..1a454b30 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -12,12 +12,14 @@ import type { ProjectsUpdatedMessage, } from '../types/app'; +import type { SessionActivityMap } from './useSessionProtection'; + type UseProjectsStateArgs = { sessionId?: string; navigate: NavigateFunction; latestMessage: AppSocketMessage | null; isMobile: boolean; - activeSessions: Set; + activeSessions: SessionActivityMap; }; type FetchProjectsOptions = { diff --git a/src/hooks/useSessionProtection.ts b/src/hooks/useSessionProtection.ts index cbdcdda1..97b05a27 100644 --- a/src/hooks/useSessionProtection.ts +++ b/src/hooks/useSessionProtection.ts @@ -1,55 +1,103 @@ import { useCallback, useState } from 'react'; +/** + * Map key for a request that is in flight before the provider has announced + * its real session id (a brand-new conversation). `session_created` migrates + * the entry to the concrete session id. + */ +export const PENDING_SESSION_ID = '__pending_session__'; + +export interface SessionActivity { + /** Provider-supplied status line; null renders the default activity label. */ + statusText: string | null; + canInterrupt: boolean; + /** + * When this request was first marked as processing (client clock). Drives + * the elapsed-time display and the stale `session-status` reply guard. + */ + startedAt: number; +} + +export type SessionActivityMap = ReadonlyMap; + +export type MarkSessionProcessing = ( + sessionId?: string | null, + activity?: { statusText?: string | null; canInterrupt?: boolean }, +) => void; + +export type MarkSessionIdle = ( + sessionId?: string | null, + opts?: { ifStartedBefore?: number }, +) => void; + +/** + * Single source of truth for which sessions are actively processing a + * request. Everything the chat UI shows (activity indicator, abort + * availability, status text) is derived from this map; terminal events + * (`complete`, `error`, abort, an authoritative idle status reply) delete the + * entry atomically. The map also drives session protection: project refreshes + * are suppressed for sessions that have an entry here. + */ export function useSessionProtection() { - const [activeSessions, setActiveSessions] = useState>(new Set()); - const [processingSessions, setProcessingSessions] = useState>(new Set()); + const [processingSessions, setProcessingSessions] = useState>( + new Map(), + ); - const markSessionAsActive = useCallback((sessionId?: string | null) => { - if (!sessionId) { - return; - } - - setActiveSessions((prev) => new Set([...prev, sessionId])); - }, []); - - const markSessionAsInactive = useCallback((sessionId?: string | null) => { - if (!sessionId) { - return; - } - - setActiveSessions((prev) => { - const next = new Set(prev); - next.delete(sessionId); - return next; - }); - }, []); - - const markSessionAsProcessing = useCallback((sessionId?: string | null) => { - if (!sessionId) { - return; - } - - setProcessingSessions((prev) => new Set([...prev, sessionId])); - }, []); - - const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => { + const markSessionProcessing = useCallback((sessionId, activity) => { if (!sessionId) { return; } setProcessingSessions((prev) => { - const next = new Set(prev); - next.delete(sessionId); - return next; + const existing = prev.get(sessionId); + const next: SessionActivity = { + statusText: + activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null, + canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true, + startedAt: existing?.startedAt ?? Date.now(), + }; + + if ( + existing + && existing.statusText === next.statusText + && existing.canInterrupt === next.canInterrupt + ) { + return prev; + } + + const updated = new Map(prev); + updated.set(sessionId, next); + return updated; + }); + }, []); + + const markSessionIdle = useCallback((sessionId, opts) => { + if (!sessionId) { + return; + } + + setProcessingSessions((prev) => { + const existing = prev.get(sessionId); + if (!existing) { + return prev; + } + + // Guard against stale `check-session-status` replies: if a new request + // started after the check was sent, the idle reply describes the older + // request and must not clear the newer one. + if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) { + return prev; + } + + const updated = new Map(prev); + updated.delete(sessionId); + return updated; }); }, []); return { - activeSessions, processingSessions, - markSessionAsActive, - markSessionAsInactive, - markSessionAsProcessing, - markSessionAsNotProcessing, + markSessionProcessing, + markSessionIdle, }; } diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 8d3f4e93..2c75fad0 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -224,6 +224,7 @@ "label": "{{time}} elapsed", "startingNow": "Starting now" }, + "stop": "Stop", "controls": { "stopGeneration": "Stop Generation", "pressEscToStop": "Press Esc anytime to stop" From 3d948217ef3084e764171ebc5dda55f663150b2c Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:38:02 +0300 Subject: [PATCH 02/18] chore: upgrade gemini models --- .../providers/list/gemini/gemini-models.provider.ts | 13 +++++-------- .../providers/services/provider-models.service.ts | 2 +- .../chat/hooks/useChatRealtimeHandlers.ts | 5 +++++ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/server/modules/providers/list/gemini/gemini-models.provider.ts b/server/modules/providers/list/gemini/gemini-models.provider.ts index fc830394..d59612cd 100644 --- a/server/modules/providers/list/gemini/gemini-models.provider.ts +++ b/server/modules/providers/list/gemini/gemini-models.provider.ts @@ -12,17 +12,14 @@ import { export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = { OPTIONS: [ - { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' }, - { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, + { value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' }, { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, - { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, - { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' }, - { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, - { value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' }, - { value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }, + { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' }, + { value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' }, + { value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' }, ], - DEFAULT: 'gemini-3.1-pro-preview', + DEFAULT: 'gemini-3-flash-preview', }; export class GeminiProviderModels implements IProviderModels { diff --git a/server/modules/providers/services/provider-models.service.ts b/server/modules/providers/services/provider-models.service.ts index 9d3402b5..9162df0c 100644 --- a/server/modules/providers/services/provider-models.service.ts +++ b/server/modules/providers/services/provider-models.service.ts @@ -17,7 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js'; export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; const PROVIDER_MODELS_CACHE_VERSION = 1; -const UNCACHED_PROVIDERS = new Set(['claude']); +const UNCACHED_PROVIDERS = new Set(['claude', 'gemini']); type ProviderModelsServiceDependencies = { resolveProvider?: (provider: LLMProvider) => Pick; diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 25e10029..816576af 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -279,6 +279,7 @@ export function useChatRealtimeHandlers({ if (isVisibleSession) { setCurrentSessionId(actualSessionId); + void sessionStore.refreshFromServer(actualSessionId); } if (isVisibleSession) { @@ -288,6 +289,10 @@ export function useChatRealtimeHandlers({ break; } + if (sid && isVisibleSession) { + void sessionStore.refreshFromServer(sid); + } + break; } From f5eac2ec12c8575bf80202fafe807d9e04720105 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:47:19 +0300 Subject: [PATCH 03/18] feat(chat): unify session gateway with stable IDs and a single WS protocol The frontend previously juggled placeholder IDs, provider-native IDs, and session_created handoffs, which caused race conditions and provider-specific branching. This introduces app-allocated session IDs, a chat run registry with event replay, delta sidebar updates, and one kind-based websocket contract so the UI can treat every provider the same while JSONL remains the source of truth. --- server/index.js | 73 ++- server/modules/database/migrations.ts | 21 + .../database/repositories/sessions.db.ts | 164 +++++- server/modules/database/schema.ts | 6 + .../tests/sessions-provider-mapping.test.ts | 108 ++++ .../projects-with-sessions-fetch.service.ts | 5 +- .../claude-session-synchronizer.provider.ts | 5 +- .../list/claude/claude-sessions.provider.ts | 23 +- .../codex-session-synchronizer.provider.ts | 10 +- .../list/codex/codex-sessions.provider.ts | 15 +- .../list/cursor/cursor-sessions.provider.ts | 35 +- .../list/gemini/gemini-sessions.provider.ts | 12 +- .../opencode-session-synchronizer.provider.ts | 5 +- .../opencode/opencode-sessions.provider.ts | 21 +- server/modules/providers/provider.routes.ts | 39 +- .../services/provider-capabilities.service.ts | 91 ++++ .../services/sessions-watcher.service.ts | 96 +++- .../providers/services/sessions.service.ts | 67 ++- server/modules/websocket/README.md | 61 ++- .../services/chat-run-registry.service.ts | 257 +++++++++ .../services/chat-session-writer.service.ts | 145 +++++ .../services/chat-websocket.service.ts | 501 ++++++++++-------- .../websocket/tests/chat-run-registry.test.ts | 207 ++++++++ server/shared/tests/slice-tail-page.test.ts | 42 ++ server/shared/types.ts | 38 ++ server/shared/utils.ts | 41 ++ src/components/app/AppContent.tsx | 28 +- .../chat/hooks/useChatComposerState.ts | 195 ++++--- .../chat/hooks/useChatProviderState.ts | 90 +++- .../chat/hooks/useChatRealtimeHandlers.ts | 469 +++++++--------- .../chat/hooks/useChatSessionState.ts | 41 +- src/components/chat/types/types.ts | 1 - src/components/chat/view/ChatInterface.tsx | 40 +- src/components/main-content/types/types.ts | 1 - .../main-content/view/MainContent.tsx | 2 - src/contexts/WebSocketContext.tsx | 81 ++- src/hooks/useProjectsState.ts | 296 ++++++----- src/hooks/useSessionProtection.ts | 19 +- src/stores/useSessionStore.ts | 302 +++-------- src/types/app.ts | 24 +- 40 files changed, 2451 insertions(+), 1226 deletions(-) create mode 100644 server/modules/database/tests/sessions-provider-mapping.test.ts create mode 100644 server/modules/providers/services/provider-capabilities.service.ts create mode 100644 server/modules/websocket/services/chat-run-registry.service.ts create mode 100644 server/modules/websocket/services/chat-session-writer.service.ts create mode 100644 server/modules/websocket/tests/chat-run-registry.test.ts create mode 100644 server/shared/tests/slice-tail-page.test.ts diff --git a/server/index.js b/server/index.js index cb8ecc31..d61c7a9b 100755 --- a/server/index.js +++ b/server/index.js @@ -22,35 +22,24 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; import { queryClaudeSDK, abortClaudeSDKSession, - isClaudeSDKSessionActive, - getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, - reconnectSessionWriter, } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, - isCursorSessionActive, - getActiveCursorSessions, } from './cursor-cli.js'; import { queryCodex, abortCodexSession, - isCodexSessionActive, - getActiveCodexSessions, } from './openai-codex.js'; import { spawnGemini, abortGeminiSession, - isGeminiSessionActive, - getActiveGeminiSessions, } from './gemini-cli.js'; import { spawnOpenCode, abortOpenCodeSession, - isOpenCodeSessionActive, - getActiveOpenCodeSessions, } from './opencode-cli.js'; import sessionManager from './sessionManager.js'; import { @@ -105,29 +94,22 @@ const wss = createWebSocketServer(server, { authenticateWebSocket, }, chat: { - queryClaudeSDK, - spawnCursor, - queryCodex, - spawnGemini, - spawnOpenCode, - abortClaudeSDKSession, - abortCursorSession, - abortCodexSession, - abortGeminiSession, - abortOpenCodeSession, + spawnFns: { + claude: queryClaudeSDK, + cursor: spawnCursor, + codex: queryCodex, + gemini: spawnGemini, + opencode: spawnOpenCode, + }, + abortFns: { + claude: abortClaudeSDKSession, + cursor: abortCursorSession, + codex: abortCodexSession, + gemini: abortGeminiSession, + opencode: abortOpenCodeSession, + }, resolveToolApproval, - isClaudeSDKSessionActive, - isCursorSessionActive, - isCodexSessionActive, - isGeminiSessionActive, - isOpenCodeSessionActive, - reconnectSessionWriter, getPendingApprovalsForSession, - getActiveClaudeSDKSessions, - getActiveCursorSessions, - getActiveCodexSessions, - getActiveGeminiSessions, - getActiveOpenCodeSessions, }, shell: { getSessionById: (sessionId) => sessionManager.getSession(sessionId), @@ -1152,6 +1134,12 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate return res.status(400).json({ error: 'Invalid sessionId' }); } + // Provider artifacts on disk (JSONL file names, OpenCode sqlite rows) + // are keyed by the provider-native session id, while the caller sends + // the app-facing id. Resolve the mapping once for all branches below. + const sessionRow = sessionsDb.getSessionById(safeSessionId); + const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId; + // Handle Cursor sessions - they use SQLite and don't have token usage info if (provider === 'cursor') { return res.json({ @@ -1252,7 +1240,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate tokens_cache_write AS cacheWriteTokens FROM session WHERE id = ? - `).get(safeSessionId); + `).get(providerNativeSessionId); if (!row) { return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId }); @@ -1293,7 +1281,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate if (entry.isDirectory()) { const found = await findSessionFile(fullPath); if (found) return found; - } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) { + } else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) { return fullPath; } } @@ -1377,12 +1365,19 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); - const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); + // Prefer the indexed transcript path (already produced by the trusted + // session synchronizer); fall back to the conventional location + // derived from the provider-native session id. + let jsonlPath = sessionRow?.jsonl_path; + if (!jsonlPath) { + jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`); - // Constrain to projectDir - const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); - if (rel.startsWith('..') || path.isAbsolute(rel)) { - return res.status(400).json({ error: 'Invalid path' }); + // Constrain the constructed path to projectDir (the id is + // caller-influenced in this fallback branch). + const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + return res.status(400).json({ error: 'Invalid path' }); + } } // Read and parse the JSONL file diff --git a/server/modules/database/migrations.ts b/server/modules/database/migrations.ts index 5b0490cb..05db26bb 100644 --- a/server/modules/database/migrations.ts +++ b/server/modules/database/migrations.ts @@ -382,6 +382,25 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { } }; +/** + * Adds the `provider_session_id` mapping column used by the session gateway. + * + * Rows that existed before this migration were always keyed directly by the + * provider-native session id, so backfilling `provider_session_id` with + * `session_id` keeps every legacy row resolvable through the new mapping. + */ +const addProviderSessionIdMapping = (db: Database): void => { + const sessionsTableInfo = getTableInfo(db, 'sessions'); + const columnNames = sessionsTableInfo.map((column) => column.name); + + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT'); + db.exec(` + UPDATE sessions + SET provider_session_id = session_id + WHERE provider_session_id IS NULL + `); +}; + const ensureProjectsForSessionPaths = (db: Database): void => { if (!tableExists(db, 'sessions')) { return; @@ -428,9 +447,11 @@ export const runMigrations = (db: Database) => { migrateLegacyWorkspaceTableIntoProjects(db); rebuildSessionsTableWithProjectSchema(db); migrateLegacySessionNames(db); + addProviderSessionIdMapping(db); ensureProjectsForSessionPaths(db); db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)'); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index d79fdeb8..a1aa26b8 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -5,6 +5,7 @@ import { normalizeProjectPath } from '@/shared/utils.js'; type SessionRow = { session_id: string; provider: string; + provider_session_id: string | null; project_path: string | null; jsonl_path: string | null; custom_name: string | null; @@ -13,10 +14,8 @@ type SessionRow = { updated_at: string; }; -type SessionMetadataLookupRow = Pick< - SessionRow, - 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at' ->; +const SESSION_ROW_COLUMNS = + 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; function normalizeTimestamp(value?: string): string | null { if (!value) return null; @@ -35,8 +34,16 @@ function normalizeProjectPathForProvider(provider: string, projectPath: string): } export const sessionsDb = { + /** + * Upserts one session row discovered on disk by a provider synchronizer. + * + * The given id is the provider-native session id. Rows are keyed by + * `provider_session_id` so a session that was first created by the app + * (with an app-allocated `session_id`) is updated in place once its + * transcript shows up on disk, instead of producing a duplicate row. + */ createSession( - sessionId: string, + providerSessionId: string, provider: string, projectPath: string, customName?: string, @@ -53,19 +60,54 @@ export const sessionsDb = { // since it's a foreign key in the sessions table. projectsDb.createProjectPath(normalizedProjectPath); + const existing = db + .prepare( + `SELECT session_id FROM sessions + WHERE provider_session_id = ? AND provider = ? + LIMIT 1` + ) + .get(providerSessionId, provider) as { session_id: string } | undefined; + + if (existing) { + db.prepare( + `UPDATE sessions SET + provider = ?, + updated_at = COALESCE(?, CURRENT_TIMESTAMP), + project_path = ?, + jsonl_path = ?, + isArchived = 0, + custom_name = COALESCE(?, custom_name) + WHERE session_id = ?` + ).run( + provider, + updatedAtValue, + normalizedProjectPath, + jsonlPath ?? null, + customName ?? null, + existing.session_id + ); + + return existing.session_id; + } + + // Sessions created outside the app (directly via the provider CLI) are + // keyed by the provider-native id for both columns. The ON CONFLICT path + // covers legacy rows that predate the provider_session_id mapping. db.prepare( - `INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) + `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, + provider_session_id = excluded.provider_session_id, updated_at = excluded.updated_at, project_path = excluded.project_path, jsonl_path = excluded.jsonl_path, isArchived = 0, custom_name = COALESCE(excluded.custom_name, sessions.custom_name)` ).run( - sessionId, + providerSessionId, provider, + providerSessionId, customName ?? null, normalizedProjectPath, jsonlPath ?? null, @@ -73,9 +115,77 @@ export const sessionsDb = { updatedAtValue ); + return providerSessionId; + }, + + /** + * Inserts one app-allocated session row before any provider run happens. + * + * The session gateway uses this when the frontend starts a brand-new chat: + * `session_id` is the stable app-facing id, while `provider_session_id` + * stays NULL until the provider runtime announces its own id and + * `assignProviderSessionId` records the mapping. + */ + createAppSession(sessionId: string, provider: string, projectPath: string): string { + const db = getConnection(); + const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath); + + projectsDb.createProjectPath(normalizedProjectPath); + + db.prepare( + `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) + VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` + ).run(sessionId, provider, normalizedProjectPath); + return sessionId; }, + /** + * Records the provider-native session id for one app-allocated session. + * + * If the filesystem watcher indexed the provider transcript before this + * mapping was recorded (a duplicate row keyed by the provider id exists), + * the duplicate is merged into the app row: its transcript path and name + * are adopted and the duplicate row is removed. Runs in a transaction so + * the sidebar can never observe both rows at once. + */ + assignProviderSessionId(sessionId: string, providerSessionId: string): void { + const db = getConnection(); + + const merge = db.transaction(() => { + const duplicate = db + .prepare( + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions + WHERE (session_id = ? OR provider_session_id = ?) + AND session_id <> ? + LIMIT 1` + ) + .get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined; + + if (duplicate) { + db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id); + db.prepare( + `UPDATE sessions SET + provider_session_id = ?, + jsonl_path = COALESCE(jsonl_path, ?), + custom_name = COALESCE(custom_name, ?), + updated_at = CURRENT_TIMESTAMP + WHERE session_id = ?` + ).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId); + return; + } + + db.prepare( + `UPDATE sessions SET + provider_session_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE session_id = ?` + ).run(providerSessionId, sessionId); + }); + + merge(); + }, + updateSessionCustomName(sessionId: string, customName: string): void { const db = getConnection(); db.prepare( @@ -85,17 +195,39 @@ export const sessionsDb = { ).run(customName, sessionId); }, - getSessionById(sessionId: string): SessionMetadataLookupRow | null { + getSessionById(sessionId: string): SessionRow | null { const db = getConnection(); const row = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE session_id = ? ORDER BY updated_at DESC LIMIT 1` ) - .get(sessionId) as SessionMetadataLookupRow | undefined; + .get(sessionId) as SessionRow | undefined; + + return row ?? null; + }, + + /** + * Resolves one session row through the provider-native id. + * + * The filesystem watcher only knows provider ids (they come from transcript + * file names), so it uses this lookup to translate disk artifacts back to + * the app-facing session row before broadcasting sidebar updates. + */ + getSessionByProviderSessionId(providerSessionId: string): SessionRow | null { + const db = getConnection(); + const row = db + .prepare( + `SELECT ${SESSION_ROW_COLUMNS} + FROM sessions + WHERE provider_session_id = ? + ORDER BY updated_at DESC + LIMIT 1` + ) + .get(providerSessionId) as SessionRow | undefined; return row ?? null; }, @@ -104,7 +236,7 @@ export const sessionsDb = { const db = getConnection(); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 0` ) @@ -119,7 +251,7 @@ export const sessionsDb = { const db = getConnection(); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 1 ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` @@ -132,7 +264,7 @@ export const sessionsDb = { const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ? AND isArchived = 0` @@ -149,7 +281,7 @@ export const sessionsDb = { const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ?` ) @@ -161,7 +293,7 @@ export const sessionsDb = { const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ? AND isArchived = 0 diff --git a/server/modules/database/schema.ts b/server/modules/database/schema.ts index b3639af2..f4cc1b53 100644 --- a/server/modules/database/schema.ts +++ b/server/modules/database/schema.ts @@ -83,6 +83,12 @@ export const SESSIONS_TABLE_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT NOT NULL, provider TEXT NOT NULL DEFAULT 'claude', + -- The session id used by the provider CLI/SDK on disk (JSONL file name, + -- store.db folder, sqlite row id, ...). \`session_id\` is the stable + -- app-facing id that the frontend uses for the whole session lifetime; + -- \`provider_session_id\` is filled in once the provider announces its own + -- id mid-run, or equals \`session_id\` for sessions discovered on disk. + provider_session_id TEXT, custom_name TEXT, project_path TEXT, jsonl_path TEXT, diff --git a/server/modules/database/tests/sessions-provider-mapping.test.ts b/server/modules/database/tests/sessions-provider-mapping.test.ts new file mode 100644 index 00000000..a9d91478 --- /dev/null +++ b/server/modules/database/tests/sessions-provider-mapping.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection } from '@/modules/database/connection.js'; +import { initializeDatabase } from '@/modules/database/init-db.js'; +import { sessionsDb } from '@/modules/database/repositories/sessions.db.js'; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-mapping-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('disk-discovered sessions are keyed by the provider id for both columns', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createSession('provider-abc', 'claude', '/workspace/demo', 'From Disk'); + + const row = sessionsDb.getSessionById('provider-abc'); + assert.equal(row?.session_id, 'provider-abc'); + assert.equal(row?.provider_session_id, 'provider-abc'); + + const byProviderId = sessionsDb.getSessionByProviderSessionId('provider-abc'); + assert.equal(byProviderId?.session_id, 'provider-abc'); + }); +}); + +test('app sessions get the provider id assigned without creating a duplicate row', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo'); + sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz'); + + // A later synchronizer pass that discovers the transcript on disk must + // update the app row in place instead of inserting a provider-keyed row. + const returnedId = sessionsDb.createSession( + 'provider-xyz', + 'claude', + '/workspace/demo', + 'Synced Name', + undefined, + undefined, + '/fake/path/provider-xyz.jsonl', + ); + + assert.equal(returnedId, 'app-id-1'); + assert.equal(sessionsDb.getAllSessions().length, 1); + + const row = sessionsDb.getSessionById('app-id-1'); + assert.equal(row?.provider_session_id, 'provider-xyz'); + assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl'); + }); +}); + +test('assignProviderSessionId merges a watcher-created duplicate into the app row', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-id-2', 'codex', '/workspace/demo'); + + // Simulate the race: the filesystem watcher indexed the provider + // transcript before the runtime announced its session id to the gateway. + sessionsDb.createSession( + 'provider-race', + 'codex', + '/workspace/demo', + 'Watcher Name', + undefined, + undefined, + '/fake/provider-race.jsonl', + ); + assert.equal(sessionsDb.getAllSessions().length, 2); + + sessionsDb.assignProviderSessionId('app-id-2', 'provider-race'); + + const rows = sessionsDb.getAllSessions(); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.session_id, 'app-id-2'); + assert.equal(rows[0]?.provider_session_id, 'provider-race'); + // Transcript path and name from the duplicate are adopted. + assert.equal(rows[0]?.jsonl_path, '/fake/provider-race.jsonl'); + assert.equal(rows[0]?.custom_name, 'Watcher Name'); + }); +}); + +test('legacy provider-keyed rows stay resolvable through both lookups', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo'); + + assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini'); + assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1'); + }); +}); diff --git a/server/modules/projects/services/projects-with-sessions-fetch.service.ts b/server/modules/projects/services/projects-with-sessions-fetch.service.ts index bb252f69..81d66ddf 100644 --- a/server/modules/projects/services/projects-with-sessions-fetch.service.ts +++ b/server/modules/projects/services/projects-with-sessions-fetch.service.ts @@ -189,10 +189,11 @@ function readProjectSessionsPageByPath( }; } -// Broadcast progress to all connected WebSocket clients +// Broadcast progress to all connected WebSocket clients. +// Uses the unified `kind` envelope like every other websocket frame. function broadcastProgress(progress: ProgressUpdate) { const message = JSON.stringify({ - type: 'loading_progress', + kind: 'loading_progress', ...progress, }); diff --git a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts index 1bf3bffc..530e4328 100644 --- a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +++ b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts @@ -111,7 +111,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { return null; } - const existingSession = sessionsDb.getSessionById(parsed.sessionId); + // App-created sessions are keyed by an app id, so disk-discovered provider + // ids must be resolved through the provider-id mapping first. + const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) + ?? sessionsDb.getSessionById(parsed.sessionId); const existingSessionName = existingSession?.custom_name; if (existingSessionName && existingSessionName !== 'Untitled Claude Session') { return { diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index f803d92c..0c7c27c2 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -5,7 +5,7 @@ import readline from 'node:readline'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; +import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; import { sessionsDb } from '@/modules/database/index.js'; const PROVIDER = 'claude'; @@ -103,10 +103,13 @@ async function parseAgentTools(filePath: string): Promise { async function getSessionMessages( sessionId: string, + providerSessionId: string, limit: number | null, offset: number, ): Promise { try { + // The DB row is keyed by the app-facing session id, while the JSONL rows + // on disk carry the provider-native id — both ids are needed here. const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path; if (!jsonLPath) { @@ -133,7 +136,7 @@ async function getSessionMessages( try { const entry = JSON.parse(line) as AnyRecord; - if (entry.sessionId === sessionId) { + if (entry.sessionId === providerSessionId) { messages.push(entry); } } catch { @@ -553,12 +556,13 @@ export class ClaudeSessionsProvider implements IProviderSessions { options: FetchHistoryOptions = {}, ): Promise { const { limit = null, offset = 0 } = options; + const providerSessionId = options.providerSessionId ?? sessionId; let result: ClaudeHistoryResult; try { // Load full history first so `total` reflects frontend-normalized messages, // not raw JSONL records. - result = await getSessionMessages(sessionId, null, 0); + result = await getSessionMessages(sessionId, providerSessionId, null, 0); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message); @@ -606,7 +610,6 @@ export class ClaudeSessionsProvider implements IProviderSessions { } } - const totalNormalized = normalized.length; let total = 0; for (const msg of normalized) { if (msg.kind !== 'tool_result') { @@ -615,18 +618,10 @@ export class ClaudeSessionsProvider implements IProviderSessions { } const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); - const messages = normalizedLimit === null - ? normalized - : normalized.slice( - Math.max(0, totalNormalized - normalizedOffset - normalizedLimit), - Math.max(0, totalNormalized - normalizedOffset), - ); - const hasMore = normalizedLimit === null - ? false - : Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0; + const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); return { - messages, + messages: page, total, hasMore, offset: normalizedOffset, diff --git a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts index 0e8025ef..818d71c9 100644 --- a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +++ b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts @@ -43,11 +43,12 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { continue; } - const existingSession = sessionsDb.getSessionById(parsed.sessionId); + const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) + ?? sessionsDb.getSessionById(parsed.sessionId); if (existingSession) { // If session name is untitled and we now have a name, update it if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') { - sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName); + sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName); } } @@ -120,7 +121,10 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { return null; } - const existingSession = sessionsDb.getSessionById(parsed.sessionId); + // App-created sessions are keyed by an app id, so disk-discovered provider + // ids must be resolved through the provider-id mapping first. + const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) + ?? sessionsDb.getSessionById(parsed.sessionId); const existingSessionName = existingSession?.custom_name; if (existingSessionName && existingSessionName !== 'Untitled Codex Session') { return { diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts index 5cad1334..d166d20c 100644 --- a/server/modules/providers/list/codex/codex-sessions.provider.ts +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -4,7 +4,7 @@ import readline from 'node:readline'; import { sessionsDb } from '@/modules/database/index.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; +import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; const PROVIDER = 'codex'; @@ -552,7 +552,6 @@ export class CodexSessionsProvider implements IProviderSessions { } } - const totalNormalized = normalized.length; let total = 0; for (const msg of normalized) { if (msg.kind !== 'tool_result') { @@ -561,18 +560,10 @@ export class CodexSessionsProvider implements IProviderSessions { } const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); - const messages = normalizedLimit === null - ? normalized - : normalized.slice( - Math.max(0, totalNormalized - normalizedOffset - normalizedLimit), - Math.max(0, totalNormalized - normalizedOffset), - ); - const hasMore = normalizedLimit === null - ? false - : Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0; + const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); return { - messages, + messages: page, total, hasMore, offset: normalizedOffset, diff --git a/server/modules/providers/list/cursor/cursor-sessions.provider.ts b/server/modules/providers/list/cursor/cursor-sessions.provider.ts index 33f93ea5..307d9638 100644 --- a/server/modules/providers/list/cursor/cursor-sessions.provider.ts +++ b/server/modules/providers/list/cursor/cursor-sessions.provider.ts @@ -9,6 +9,7 @@ import { generateMessageId, readObjectRecord, sanitizeLeafDirectoryName, + sliceTailPage, } from '@/shared/utils.js'; const PROVIDER = 'cursor'; @@ -363,42 +364,32 @@ export class CursorSessionsProvider implements IProviderSessions { /** * Fetches and paginates Cursor session history from its project-scoped store.db. + * + * Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is + * the most recent page, matching every other provider. */ async fetchHistory( sessionId: string, options: FetchHistoryOptions = {}, ): Promise { const { projectPath = '', limit = null, offset = 0 } = options; + // The store.db folder on disk is named after the provider-native id, not + // the app-facing session id this method is addressed with. + const providerSessionId = options.providerSessionId ?? sessionId; try { - const blobs = await this.loadCursorBlobs(sessionId, projectPath); + const blobs = await this.loadCursorBlobs(providerSessionId, projectPath); const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result'); const total = renderableMessages.length; - - if (limit !== null) { - const start = offset; - const page = limit === 0 - ? [] - : renderableMessages.slice(start, start + limit); - const hasMore = limit === 0 - ? start < total - : start + limit < total; - return { - messages: page, - total, - hasMore, - offset, - limit, - }; - } + const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset); return { - messages: renderableMessages, + messages: page, total, - hasMore: false, - offset: 0, - limit: null, + hasMore, + offset, + limit, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/server/modules/providers/list/gemini/gemini-sessions.provider.ts b/server/modules/providers/list/gemini/gemini-sessions.provider.ts index 32781c9d..4046919a 100644 --- a/server/modules/providers/list/gemini/gemini-sessions.provider.ts +++ b/server/modules/providers/list/gemini/gemini-sessions.provider.ts @@ -5,7 +5,7 @@ import readline from 'node:readline'; import { sessionsDb } from '@/modules/database/index.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; +import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; const PROVIDER = 'gemini'; @@ -518,9 +518,9 @@ export class GeminiSessionsProvider implements IProviderSessions { const start = Math.max(0, offset); const pageLimit = limit === null ? null : Math.max(0, limit); - const messages = pageLimit === null - ? normalized.slice(start) - : normalized.slice(start, start + pageLimit); + // Tail pagination via the shared contract: offset 0 returns the most + // recent page, matching every other provider. + const { page, hasMore } = sliceTailPage(normalized, pageLimit, start); let total = 0; for (const msg of normalized) { if (msg.kind !== 'tool_result') { @@ -529,9 +529,9 @@ export class GeminiSessionsProvider implements IProviderSessions { } return { - messages, + messages: page, total, - hasMore: pageLimit === null ? false : start + pageLimit < normalized.length, + hasMore, offset: start, limit: pageLimit, tokenUsage: result.tokenUsage, diff --git a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts index bd9ad9fe..8e7ee213 100644 --- a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +++ b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts @@ -112,7 +112,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer } const fallbackTitle = 'Untitled OpenCode Session'; - const existingSession = sessionsDb.getSessionById(sessionId); + // App-created sessions are keyed by an app id, so disk-discovered provider + // ids must be resolved through the provider-id mapping first. + const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) + ?? sessionsDb.getSessionById(sessionId); const existingName = existingSession?.custom_name; const nextName = existingName && existingName !== fallbackTitle ? existingName diff --git a/server/modules/providers/list/opencode/opencode-sessions.provider.ts b/server/modules/providers/list/opencode/opencode-sessions.provider.ts index 5b7bcce7..6da4857d 100644 --- a/server/modules/providers/list/opencode/opencode-sessions.provider.ts +++ b/server/modules/providers/list/opencode/opencode-sessions.provider.ts @@ -12,6 +12,7 @@ import { readObjectRecord, readJsonRecord, readOptionalString, + sliceTailPage, } from '@/shared/utils.js'; const PROVIDER = 'opencode'; @@ -325,6 +326,9 @@ export class OpenCodeSessionsProvider implements IProviderSessions { options: FetchHistoryOptions = {}, ): Promise { const { limit = null, offset = 0 } = options; + // OpenCode's shared sqlite database keys messages by the provider-native + // session id, not the app-facing id this method is addressed with. + const providerSessionId = options.providerSessionId ?? sessionId; const db = openOpenCodeDatabase(); if (!db) { return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; @@ -349,27 +353,20 @@ export class OpenCodeSessionsProvider implements IProviderSessions { m.id, COALESCE(p.time_created, 0), p.id - `).all(sessionId) as OpenCodeHistoryRow[]; + `).all(providerSessionId) as OpenCodeHistoryRow[]; const normalized = this.normalizeHistoryRows(rows, sessionId); - const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId); + const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId); const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); const total = normalized.length; - const messages = normalizedLimit === null - ? normalized - : normalized.slice( - Math.max(0, total - normalizedOffset - normalizedLimit), - Math.max(0, total - normalizedOffset), - ); + const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); return { - messages, + messages: page, total, - hasMore: normalizedLimit === null - ? false - : Math.max(0, total - normalizedOffset - normalizedLimit) > 0, + hasMore, offset: normalizedOffset, limit: normalizedLimit, tokenUsage, diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 2604fcc8..14f95080 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -1,6 +1,7 @@ import express, { type Request, type Response } from 'express'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; +import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; import { providerModelsService } from '@/modules/providers/services/provider-models.service.js'; import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; @@ -382,7 +383,43 @@ router.post( }), ); +router.get( + '/capabilities', + asyncHandler(async (_req: Request, res: Response) => { + res.json(createApiSuccessResponse({ + providers: providerCapabilitiesService.listAllProviderCapabilities(), + })); + }), +); + +router.get( + '/:provider/capabilities', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + res.json(createApiSuccessResponse( + providerCapabilitiesService.getProviderCapabilities(provider), + )); + }), +); + // ----------------- Session routes ----------------- +/** + * Session gateway entry point: allocates the stable app-facing session id for + * a brand-new chat. The frontend must call this before the first `chat.send` + * so the session id in the URL, the store, and the websocket all agree from + * the very first message — there is no client-visible session-id handoff. + */ +router.post( + '/sessions', + asyncHandler(async (req: Request, res: Response) => { + const body = (req.body ?? {}) as Record; + const provider = parseProvider(body.provider); + const projectPath = typeof body.projectPath === 'string' ? body.projectPath : ''; + const result = sessionsService.createAppSession(provider, projectPath); + res.status(201).json(createApiSuccessResponse(result)); + }), +); + router.get( '/sessions/archived', asyncHandler(async (_req: Request, res: Response) => { @@ -459,7 +496,7 @@ router.get( limit, offset, }); - res.json(result); + res.json(createApiSuccessResponse(result)); }), ); diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts new file mode 100644 index 00000000..1b7cbbb3 --- /dev/null +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -0,0 +1,91 @@ +import type { LLMProvider } from '@/shared/types.js'; + +/** + * Static, backend-owned description of what one provider integration supports. + * + * The frontend renders its composer UI (permission mode picker, image upload, + * abort button, ...) purely from this shape, which is what keeps the frontend + * free of per-provider conditionals. New provider features should be exposed + * here instead of branching on the provider id in React components. + */ +type ProviderCapabilities = { + provider: LLMProvider; + /** Permission modes the provider runtime understands, in cycle order. */ + permissionModes: string[]; + defaultPermissionMode: string; + /** Whether image attachments can be included in a chat.send. */ + supportsImages: boolean; + /** Whether an in-flight run can be cancelled via chat.abort. */ + supportsAbort: boolean; + /** Whether interactive tool permission prompts can reach the UI. */ + supportsPermissionRequests: boolean; + /** Whether the token-usage endpoint has data for this provider. */ + supportsTokenUsage: boolean; +}; + +/** + * The capability matrix mirrors what each runtime actually implements today: + * - permission modes match the option sets accepted by each CLI/SDK. + * - only the Claude SDK integration surfaces interactive permission requests. + * - Cursor has no token usage endpoint support (its store.db has no usage rows). + */ +const PROVIDER_CAPABILITIES: Record = { + claude: { + provider: 'claude', + permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'], + defaultPermissionMode: 'default', + supportsImages: true, + supportsAbort: true, + supportsPermissionRequests: true, + supportsTokenUsage: true, + }, + cursor: { + provider: 'cursor', + permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: false, + }, + codex: { + provider: 'codex', + permissionModes: ['default', 'acceptEdits', 'bypassPermissions'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: true, + }, + gemini: { + provider: 'gemini', + permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: true, + }, + opencode: { + provider: 'opencode', + permissionModes: ['default'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: true, + }, +}; + +/** + * Application service exposing the provider capability matrix. + */ +export const providerCapabilitiesService = { + getProviderCapabilities(provider: LLMProvider): ProviderCapabilities { + return PROVIDER_CAPABILITIES[provider]; + }, + + listAllProviderCapabilities(): ProviderCapabilities[] { + return Object.values(PROVIDER_CAPABILITIES); + }, +}; diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 7e0ab36a..cfbdb887 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -4,10 +4,11 @@ import { promises as fsPromises } from 'node:fs'; import chokidar, { type FSWatcher } from 'chokidar'; +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js'; import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js'; import type { LLMProvider } from '@/shared/types.js'; -import { getProjectsWithSessions } from '@/modules/projects/index.js'; +import { generateDisplayName } from '@/modules/projects/index.js'; type WatcherEventType = 'add' | 'change'; @@ -58,6 +59,11 @@ const watchers: FSWatcher[] = []; type PendingWatcherUpdate = { providers: Set; changeTypes: Set; + /** + * Provider-native session ids reported by the synchronizers. They are + * translated back to app-facing session rows at flush time, because the + * transcript file names on disk only ever contain provider ids. + */ updatedSessionIds: Set; }; @@ -131,6 +137,50 @@ function queuePendingWatcherUpdate( schedulePendingWatcherFlush(); } +/** + * Builds one `session_upserted` delta event for a provider-native session id. + * + * The event carries everything a sidebar needs to upsert the session in place + * (session summary plus owning-project metadata), so clients never need a full + * project-list refetch when a transcript file changes on disk. Returns `null` + * when the id cannot be resolved to an indexed session row. + */ +async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise { + const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId) + ?? sessionsDb.getSessionById(updatedProviderSessionId); + if (!row || row.isArchived) { + return null; + } + + const projectPath = row.project_path; + const project = projectPath ? projectsDb.getProjectPath(projectPath) : null; + const displayName = project?.custom_project_name?.trim() + ? project.custom_project_name + : await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath); + + return JSON.stringify({ + kind: 'session_upserted', + sessionId: row.session_id, + provider: row.provider, + session: { + id: row.session_id, + summary: row.custom_name || '', + messageCount: 0, + lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), + }, + project: project + ? { + projectId: project.project_id, + path: project.project_path, + fullPath: project.project_path, + displayName, + isStarred: Boolean(project.isStarred), + } + : null, + timestamp: new Date().toISOString(), + }); +} + async function flushPendingWatcherUpdate(): Promise { clearPendingWatcherFlushTimer(); @@ -149,33 +199,29 @@ async function flushPendingWatcherUpdate(): Promise { watcherRefreshInFlight = true; try { - const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true }); - const changeTypes = Array.from(queuedUpdate.changeTypes); - const watchProviders = Array.from(queuedUpdate.providers); - const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds); - - // Backward-compatible fields stay populated with the first queued values. - const updateMessage = JSON.stringify({ - type: 'projects_updated', - projects: updatedProjects, - timestamp: new Date().toISOString(), - changeType: changeTypes[0] ?? 'change', - updatedSessionId: updatedSessionIds[0] ?? undefined, - watchProvider: watchProviders[0] ?? undefined, - changeTypes, - updatedSessionIds, - watchProviders, - batched: true, - }); - - connectedClients.forEach(client => { - if (client.readyState === WS_OPEN_STATE) { - client.send(updateMessage); + // Per-session deltas instead of full project snapshots: an upsert of one + // session can never clobber unrelated client state, so the frontend needs + // no "suppress updates while a run is active" protection logic. + const events: string[] = []; + for (const updatedSessionId of queuedUpdate.updatedSessionIds) { + const event = await buildSessionUpsertedEvent(updatedSessionId); + if (event) { + events.push(event); } - }); + } + + if (events.length > 0) { + connectedClients.forEach(client => { + if (client.readyState === WS_OPEN_STATE) { + for (const event of events) { + client.send(event); + } + } + }); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message }); + console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message }); } finally { watcherRefreshInFlight = false; diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 49b5dcb7..7379b60b 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import fsp from 'node:fs/promises'; import path from 'node:path'; @@ -11,6 +12,12 @@ import type { } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; +type CreateAppSessionResult = { + sessionId: string; + provider: LLMProvider; + projectPath: string; +}; + type ArchivedSessionListItem = { sessionId: string; provider: LLMProvider; @@ -89,12 +96,43 @@ export const sessionsService = { }, /** - * Fetches persisted history by session id. + * Allocates a stable app-facing session id before any provider run happens. + * + * This is the entry point of the session gateway: the frontend calls this + * (via `POST /api/providers/sessions`) when the user starts a brand-new + * chat, navigates to the returned id immediately, and the id never changes + * for the lifetime of the conversation. The provider-native id is mapped to + * this row later, when the provider runtime announces it mid-run. + */ + createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult { + const normalizedProjectPath = projectPath.trim(); + if (!normalizedProjectPath) { + throw new AppError('projectPath is required.', { + code: 'PROJECT_PATH_REQUIRED', + statusCode: 400, + }); + } + + const sessionId = randomUUID(); + sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath); + + return { + sessionId, + provider, + projectPath: normalizedProjectPath, + }; + }, + + /** + * Fetches persisted history by app session id. * * Provider and provider-specific lookup hints are resolved from the indexed - * session metadata in the database. + * session metadata in the database. The provider adapter receives the + * provider-native session id (the one written into transcripts on disk), + * and every returned message is remapped back to the app session id so + * provider ids never reach the frontend. */ - fetchHistory( + async fetchHistory( sessionId: string, options: Pick = {}, ): Promise { @@ -106,12 +144,33 @@ export const sessionsService = { }); } + // App-created sessions that never produced a provider transcript yet + // (e.g. first message still streaming) simply have no history. + if (!session.provider_session_id) { + return { + messages: [], + total: 0, + hasMore: false, + offset: options.offset ?? 0, + limit: options.limit ?? null, + }; + } + const provider = session.provider as LLMProvider; - return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, { + const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, { limit: options.limit ?? null, offset: options.offset ?? 0, projectPath: session.project_path ?? '', + providerSessionId: session.provider_session_id, }); + + return { + ...result, + messages: result.messages.map((message) => ({ + ...message, + sessionId, + })), + }; }, /** diff --git a/server/modules/websocket/README.md b/server/modules/websocket/README.md index f3fe7a13..660e36ab 100644 --- a/server/modules/websocket/README.md +++ b/server/modules/websocket/README.md @@ -33,10 +33,12 @@ Benefits: |---|---| | `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname | | `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` | -| `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages | +| `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.subscribe` / `chat.permission-response`) | +| `services/chat-run-registry.service.ts` | Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state | +| `services/chat-session-writer.service.ts` | Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows `session_created`, assigns `seq` | | `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection | | `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket | -| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) | +| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) for non-chat writer consumers | | `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant | ## High-Level Architecture @@ -52,12 +54,12 @@ flowchart LR D -->|other| H[close()] E --> I[connectedClients Set] - E --> J[WebSocketWriter] + E --> J[chatRunRegistry + ChatSessionWriter] F --> K[ptySessionsMap] G --> L[Upstream Plugin ws://127.0.0.1:port/ws] - I --> M[projects.service broadcastProgress] - I --> N[sessions-watcher.service projects_updated] + I --> M[projects.service loading_progress] + I --> N[sessions-watcher.service session_upserted] ``` ## Connection Handshake + Routing @@ -105,38 +107,41 @@ sequenceDiagram When a chat socket connects: 1. Add socket to `connectedClients`. -2. Build `WebSocketWriter` (captures `userId` from authenticated request). -3. Parse each incoming message with `parseIncomingJsonObject`. -4. Dispatch by `data.type`. -5. On close, remove socket from `connectedClients`. +2. Parse each incoming message with `parseIncomingJsonObject`. +3. Dispatch by `data.type` (four message types, none provider-specific). +4. On close, remove socket from `connectedClients`. + +### Session identity model + +The frontend only ever knows the **app session id** (allocated by +`POST /api/providers/sessions` or discovered via the session index). The +provider-native id (JSONL file name, CLI resume id) stays inside the backend: + +1. `chat.send` resolves the app id to `{ provider, provider_session_id, project_path }` from the sessions DB. +2. The provider runtime receives the provider-native id for resume. +3. The `ChatSessionWriter` remaps every outbound event back to the app id, and turns `session_created` announcements into a DB mapping update instead of forwarding them. ### Chat Message Dispatch ```mermaid flowchart TD A[Incoming WS message] --> B[parseIncomingJsonObject] - B -->|invalid| C[send {type:error}] + B -->|invalid| C[send kind:protocol_error] B -->|ok| D{data.type} - D -->|claude-command| E[queryClaudeSDK] - D -->|cursor-command| F[spawnCursor] - D -->|codex-command| G[queryCodex] - D -->|gemini-command| H[spawnGemini] - D -->|cursor-resume| I[spawnCursor resume] - D -->|abort-session| J[abort by provider] - D -->|claude-permission-response| K[resolveToolApproval] - D -->|cursor-abort| L[abortCursorSession] - D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter] - D -->|get-pending-permissions| N[getPendingApprovalsForSession] - D -->|get-active-sessions| O[getActive*Sessions] + D -->|chat.send| E[resolve session row -> startRun -> spawnFns provider] + D -->|chat.abort| F[abortFns provider + synthetic complete] + D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq] + D -->|chat.permission-response| H[resolveToolApproval] + D -->|other| I[send kind:protocol_error] ``` ### Chat Notes -1. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`), regardless of provider: `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. Failed runs emit an informational `error` message first, then the terminal `complete` with `success: false`. Mid-run `error` messages (e.g. stderr output) are non-terminal; the frontend only treats `complete` as end-of-run. -2. `abort-session` sends the terminal `complete` (`aborted: true`) on behalf of the cancelled run; providers detect the abort and skip their own `complete` so the client sees exactly one. -3. `check-session-status` returns `{ type: "session-status", isProcessing }`. -4. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`. +1. **Unified envelope**: every server-to-client frame carries a `kind` — either a provider `NormalizedMessage` kind or a gateway kind (`chat_subscribed`, `session_upserted`, `loading_progress`, `protocol_error`). There is no second `type`-based protocol. +2. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`): `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. The chat handler emits a synthetic `complete` for runs that crash or get aborted, and the run registry drops duplicate completes. +3. **Per-run event log**: every live event gets a monotonically increasing `seq`. `chat.subscribe { sessions: [{ sessionId, lastSeq }] }` re-attaches the live stream to the requesting socket (any provider, not just Claude) and replays events with `seq > lastSeq`. If the buffer no longer covers `lastSeq`, the client refreshes over REST. +4. `chat_subscribed` includes `isProcessing` (replaces `check-session-status`) and `pendingPermissions` (replaces `get-pending-permissions`). ## `/shell` Terminal Flow @@ -224,9 +229,9 @@ Only chat sockets (`/ws`) are tracked in `connectedClients`. That shared set is consumed by: 1. `modules/projects/services/projects-with-sessions-fetch.service.ts` -Broadcasts `loading_progress` while project snapshots are being built. +Broadcasts `kind: loading_progress` while project snapshots are being built. 2. `modules/providers/services/sessions-watcher.service.ts` -Broadcasts `projects_updated` when provider session artifacts change. +Broadcasts per-session `kind: session_upserted` deltas when provider session artifacts change (no full project snapshots). This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals. @@ -253,7 +258,7 @@ Current explicit close codes in this module: Other errors: -1. Chat handler catches and emits `{ type: "error", error }`. +1. Chat handler catches and emits `{ kind: "protocol_error", code, error }`. 2. Shell handler catches and writes terminal-visible error output. 3. Unknown websocket paths are closed immediately. diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts new file mode 100644 index 00000000..ae8852bf --- /dev/null +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -0,0 +1,257 @@ +import { sessionsDb } from '@/modules/database/index.js'; +import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js'; +import type { + LLMProvider, + NormalizedMessage, + RealtimeClientConnection, +} from '@/shared/types.js'; + +type ChatRunStatus = 'running' | 'completed'; + +/** + * One live (or recently finished) provider run for a single app session. + * + * State notes — why each mutable field is essential: + * - `providerSessionId`: the provider-native id captured mid-run. The abort + * handler needs it to address the provider runtime, and the DB mapping is + * written from it so history/resume work after the run. + * - `status`: drives `chat_subscribed.isProcessing`, prevents double sends + * into the same session, and guards the synthetic-complete fallback in the + * chat handler (only emitted when a runtime died without completing). + * - `lastSeq` / `events`: the per-run event log. Every live event gets a + * monotonically increasing `seq` and is buffered so a reconnecting client + * can replay exactly the events it missed via `chat.subscribe`. + */ +type ChatRun = { + appSessionId: string; + provider: LLMProvider; + providerSessionId: string | null; + status: ChatRunStatus; + lastSeq: number; + events: NormalizedMessage[]; + writer: ChatSessionWriter; + startedAt: number; + completedAt: number | null; +}; + +/** + * How long a completed run stays available for replay. Covers the window + * between a run finishing and the client refreshing history over REST (for + * example when the browser tab was asleep while the run completed). + */ +const COMPLETED_RUN_RETENTION_MS = 5 * 60 * 1000; + +/** + * Upper bound on buffered events per run so a very long tool-heavy run cannot + * grow memory unbounded. When exceeded, the oldest events are dropped — + * a reconnecting client whose `lastSeq` predates the buffer falls back to a + * REST history refresh, which is always the authoritative source. + */ +const MAX_BUFFERED_EVENTS_PER_RUN = 5000; + +/** + * Active and recently-completed runs keyed by app session id. + * + * This map is the single in-memory source of truth for "is something running + * for this session" — the chat websocket handler, abort path, and subscribe + * path all consult it instead of asking each provider runtime individually. + */ +const runs = new Map(); + +function evictRunLater(appSessionId: string): void { + const timer = setTimeout(() => { + const run = runs.get(appSessionId); + if (run && run.status === 'completed') { + runs.delete(appSessionId); + } + }, COMPLETED_RUN_RETENTION_MS); + + // Never keep the process alive just to evict a buffered run. + timer.unref?.(); +} + +/** + * Decorates one outbound live event for a run and records it in the event log. + * + * Responsibilities: + * 1. Remap `sessionId` (and `actualSessionId` on `complete`) to the stable + * app session id — provider-native ids never leave the backend. + * 2. Assign the next `seq` so clients can detect/replay gaps. + * 3. Buffer the event for `chat.subscribe` replay. + * 4. Flip the run to `completed` when the terminal `complete` event passes by. + */ +function decorateAndRecordEvent(run: ChatRun, message: NormalizedMessage): NormalizedMessage | null { + // Exactly-one-complete contract: when a run is aborted the chat handler + // emits the terminal `complete` immediately, but the killed runtime may + // still emit its own `complete` from its exit handler moments later. + // Whichever arrives first wins; the duplicate is dropped here. + if (message.kind === 'complete' && run.status === 'completed') { + return null; + } + + run.lastSeq += 1; + + const outbound: NormalizedMessage = { + ...message, + sessionId: run.appSessionId, + seq: run.lastSeq, + }; + + if (message.kind === 'complete') { + // The provider may report its own id here; the frontend only ever knows + // the app id, so the "actual" id is by definition the app id as well. + outbound.actualSessionId = run.appSessionId; + run.status = 'completed'; + run.completedAt = Date.now(); + evictRunLater(run.appSessionId); + } + + run.events.push(outbound); + if (run.events.length > MAX_BUFFERED_EVENTS_PER_RUN) { + run.events.splice(0, run.events.length - MAX_BUFFERED_EVENTS_PER_RUN); + } + + return outbound; +} + +/** + * Records the provider-native session id for a run and persists the + * app-id-to-provider-id mapping so history fetches and future resumes can + * address the provider transcript. + * + * Called from the gateway writer when the runtime either calls + * `setSessionId(...)` or emits its `session_created` event — whichever + * happens first wins; later calls with the same id are no-ops. + */ +function recordProviderSessionId(run: ChatRun, providerSessionId: string): void { + if (!providerSessionId || run.providerSessionId === providerSessionId) { + return; + } + + run.providerSessionId = providerSessionId; + + try { + sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[ChatRunRegistry] Failed to persist provider session id mapping', { + appSessionId: run.appSessionId, + providerSessionId, + error: message, + }); + } +} + +/** + * Registry of live provider runs keyed by the stable app session id. + * + * The registry is what makes the websocket protocol provider-independent: + * every run gets a `ChatSessionWriter` that remaps provider-native session + * ids to the app id, assigns `seq` numbers, and buffers events for replay — + * regardless of which provider runtime produced them. + */ +export const chatRunRegistry = { + /** + * Starts tracking a run and returns it, or `null` when a run is already in + * progress for the session (callers must reject the duplicate send). + */ + startRun(input: { + appSessionId: string; + provider: LLMProvider; + providerSessionId: string | null; + connection: RealtimeClientConnection; + userId: string | number | null; + }): ChatRun | null { + const existing = runs.get(input.appSessionId); + if (existing && existing.status === 'running') { + return null; + } + + const run: ChatRun = { + appSessionId: input.appSessionId, + provider: input.provider, + providerSessionId: input.providerSessionId, + status: 'running', + lastSeq: 0, + events: [], + writer: null as unknown as ChatSessionWriter, + startedAt: Date.now(), + completedAt: null, + }; + + run.writer = new ChatSessionWriter({ + connection: input.connection, + userId: input.userId, + provider: input.provider, + providerSessionId: input.providerSessionId, + onProviderSessionId: (providerSessionId) => { + recordProviderSessionId(run, providerSessionId); + }, + decorateOutboundEvent: (message) => decorateAndRecordEvent(run, message), + }); + + runs.set(input.appSessionId, run); + return run; + }, + + getRun(appSessionId: string): ChatRun | undefined { + return runs.get(appSessionId); + }, + + isProcessing(appSessionId: string): boolean { + return runs.get(appSessionId)?.status === 'running'; + }, + + /** + * Re-attaches a run's outbound stream to a (new) websocket connection. + * + * This is the generic replacement for the Claude-only writer reconnect: + * after a page refresh the new socket subscribes and immediately starts + * receiving the still-running stream, for every provider. + */ + attachConnection(appSessionId: string, connection: RealtimeClientConnection): boolean { + const run = runs.get(appSessionId); + if (!run) { + return false; + } + + run.writer.updateWebSocket(connection); + return true; + }, + + /** + * Returns buffered events with `seq` greater than `afterSeq` for replay. + * + * An empty array with `run.lastSeq > afterSeq` not covered by the buffer + * means the buffer was truncated; the client should refresh over REST. + */ + replayEvents(appSessionId: string, afterSeq: number): NormalizedMessage[] { + const run = runs.get(appSessionId); + if (!run) { + return []; + } + + return run.events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq); + }, + + /** + * Emits a synthetic terminal `complete` if (and only if) the run is still + * marked running. Used when a provider runtime throws or resolves without + * having produced its own terminal event, and by the abort path. + */ + completeRun(appSessionId: string, opts: { exitCode: number; aborted?: boolean }): void { + const run = runs.get(appSessionId); + if (!run || run.status !== 'running') { + return; + } + + run.writer.sendComplete(opts); + }, + + /** + * Test-only escape hatch: clears every tracked run. + */ + clearAll(): void { + runs.clear(); + }, +}; diff --git a/server/modules/websocket/services/chat-session-writer.service.ts b/server/modules/websocket/services/chat-session-writer.service.ts new file mode 100644 index 00000000..82805195 --- /dev/null +++ b/server/modules/websocket/services/chat-session-writer.service.ts @@ -0,0 +1,145 @@ +import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; +import type { + LLMProvider, + NormalizedMessage, + RealtimeClientConnection, +} from '@/shared/types.js'; +import { createCompleteMessage, readObjectRecord } from '@/shared/utils.js'; + +type ChatSessionWriterOptions = { + connection: RealtimeClientConnection; + userId: string | number | null; + provider: LLMProvider; + /** Provider-native id when resuming an existing session, otherwise null. */ + providerSessionId: string | null; + /** + * Invoked the moment the provider runtime reveals its native session id + * (either via `setSessionId` or a `session_created` event). The registry + * persists the app-id-to-provider-id mapping from this callback. + */ + onProviderSessionId: (providerSessionId: string) => void; + /** + * Remaps/sequences/buffers one outbound live event. Implemented by the chat + * run registry; the writer never forwards a provider event untouched. + * Returns `null` when the event must be dropped (duplicate terminal + * `complete` after an abort already completed the run). + */ + decorateOutboundEvent: (message: NormalizedMessage) => NormalizedMessage | null; +}; + +/** + * Gateway writer handed to provider runtimes instead of a raw websocket writer. + * + * It exposes the exact same surface as `WebSocketWriter` (`send`, + * `setSessionId`, `getSessionId`, `updateWebSocket`, `userId`, + * `isWebSocketWriter`) so the provider runtimes (`claude-sdk.js`, + * `cursor-cli.js`, ...) need zero changes — but everything that flows through + * it is translated from the provider's world into the app's protocol: + * + * - `session_created` events are swallowed and turned into a provider-id + * mapping; the frontend never learns provider-native ids. + * - every other event gets `sessionId` remapped to the app session id and a + * per-run `seq` assigned before being forwarded. + * - `setSessionId(...)` calls (used by runtimes to label captured ids) are + * intercepted and recorded as the provider-id mapping as well. + */ +export class ChatSessionWriter { + ws: RealtimeClientConnection; + userId: string | number | null; + /** + * Some runtimes feature-detect their writer with this flag; keep it so the + * gateway writer is a drop-in replacement for `WebSocketWriter`. + */ + isWebSocketWriter = true; + + private readonly options: ChatSessionWriterOptions; + /** + * The provider-native session id as the runtime knows it. Kept locally + * (besides the registry) because runtimes read it back via `getSessionId()` + * to label their own outgoing events — those labels are remapped on send + * anyway, but the runtime-visible value must stay provider-native. + */ + private providerSessionId: string | null; + + constructor(options: ChatSessionWriterOptions) { + this.options = options; + this.ws = options.connection; + this.userId = options.userId; + this.providerSessionId = options.providerSessionId; + } + + send(data: unknown): void { + const record = readObjectRecord(data); + if (!record || typeof record.kind !== 'string') { + // Provider runtimes only emit kind-based normalized messages. Anything + // else indicates a programming error; drop it rather than leaking an + // un-remapped payload to the client. + console.error('[ChatSessionWriter] Dropping non-normalized outbound payload', data); + return; + } + + const message = record as NormalizedMessage; + + if (message.kind === 'session_created') { + const announcedId = + typeof message.newSessionId === 'string' && message.newSessionId + ? message.newSessionId + : message.sessionId; + if (announcedId) { + this.captureProviderSessionId(announcedId); + } + // Swallowed on purpose: the frontend already has the stable app session + // id, so there is no client-side handoff to perform anymore. + return; + } + + const outbound = this.options.decorateOutboundEvent(message); + if (outbound) { + this.forward(outbound); + } + } + + /** + * Emits the synthetic terminal `complete` for runs that ended without one + * (runtime crash before completing, or user abort). + */ + sendComplete(opts: { exitCode: number; aborted?: boolean }): void { + const message = createCompleteMessage({ + provider: this.options.provider, + sessionId: this.providerSessionId, + exitCode: opts.exitCode, + aborted: opts.aborted, + }); + const outbound = this.options.decorateOutboundEvent(message); + if (outbound) { + this.forward(outbound); + } + } + + updateWebSocket(newConnection: RealtimeClientConnection): void { + this.ws = newConnection; + } + + setSessionId(sessionId: string): void { + this.captureProviderSessionId(sessionId); + } + + getSessionId(): string | null { + return this.providerSessionId; + } + + private captureProviderSessionId(providerSessionId: string): void { + if (!providerSessionId || this.providerSessionId === providerSessionId) { + return; + } + + this.providerSessionId = providerSessionId; + this.options.onProviderSessionId(providerSessionId); + } + + private forward(message: NormalizedMessage): void { + if (this.ws.readyState === WS_OPEN_STATE) { + this.ws.send(JSON.stringify(message)); + } + } +} diff --git a/server/modules/websocket/services/chat-websocket.service.ts b/server/modules/websocket/services/chat-websocket.service.ts index 67833c33..4e676ec3 100644 --- a/server/modules/websocket/services/chat-websocket.service.ts +++ b/server/modules/websocket/services/chat-websocket.service.ts @@ -1,40 +1,35 @@ import type { WebSocket } from 'ws'; -import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js'; -import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js'; +import { sessionsDb } from '@/modules/database/index.js'; +import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; +import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; import type { AnyRecord, AuthenticatedWebSocketRequest, LLMProvider, } from '@/shared/types.js'; -import { createCompleteMessage, parseIncomingJsonObject } from '@/shared/utils.js'; +import { parseIncomingJsonObject } from '@/shared/utils.js'; -type ChatIncomingMessage = AnyRecord & { - type?: string; - command?: string; - options?: AnyRecord; - provider?: string; - sessionId?: string; - requestId?: string; - allow?: unknown; - updatedInput?: unknown; - message?: unknown; - rememberEntry?: unknown; -}; - -const DEFAULT_PROVIDER: LLMProvider = 'claude'; +/** + * One provider runtime entry point. All five runtimes share this signature, + * which lets the chat handler dispatch through a provider-keyed map instead + * of provider-specific branches. + */ +type ProviderSpawnFn = ( + command: string, + options: AnyRecord, + writer: unknown +) => Promise; type ChatWebSocketDependencies = { - queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - abortClaudeSDKSession: (sessionId: string) => Promise; - abortCursorSession: (sessionId: string) => boolean; - abortCodexSession: (sessionId: string) => boolean; - abortGeminiSession: (sessionId: string) => boolean; - abortOpenCodeSession: (sessionId: string) => boolean; + /** Provider runtimes keyed by provider id. */ + spawnFns: Record; + /** + * Abort functions keyed by provider id. They are addressed with the + * provider-native session id (that is how runtimes key their process maps). + * The Claude abort is async; the rest are sync — both shapes are accepted. + */ + abortFns: Record boolean | Promise>; resolveToolApproval: ( requestId: string, payload: { @@ -44,31 +39,10 @@ type ChatWebSocketDependencies = { rememberEntry?: unknown; } ) => void; - isClaudeSDKSessionActive: (sessionId: string) => boolean; - isCursorSessionActive: (sessionId: string) => boolean; - isCodexSessionActive: (sessionId: string) => boolean; - isGeminiSessionActive: (sessionId: string) => boolean; - isOpenCodeSessionActive: (sessionId: string) => boolean; - reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean; - getPendingApprovalsForSession: (sessionId: string) => unknown[]; - getActiveClaudeSDKSessions: () => unknown; - getActiveCursorSessions: () => unknown; - getActiveCodexSessions: () => unknown; - getActiveGeminiSessions: () => unknown; - getActiveOpenCodeSessions: () => unknown; + /** Claude-only today: pending tool approvals included in `chat_subscribed`. */ + getPendingApprovalsForSession: (providerSessionId: string) => unknown[]; }; -/** - * Normalizes potentially invalid provider names coming from websocket payloads. - */ -function readProvider(value: unknown): LLMProvider { - if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') { - return value; - } - - return DEFAULT_PROVIDER; -} - /** * Extracts the authenticated request user id in the formats currently produced * by platform and OSS auth code paths. @@ -92,8 +66,258 @@ function readRequestUserId( return null; } +function sendJson(ws: WebSocket, payload: unknown): void { + if (ws.readyState === WS_OPEN_STATE) { + ws.send(JSON.stringify(payload)); + } +} + +/** + * Reports a protocol-level failure to the requesting client. + * + * Protocol errors deliberately use their own `kind` (instead of the provider + * `error` message kind) so the frontend can distinguish "your request was + * invalid" from "the model run produced an error" without inspecting text. + */ +function sendProtocolError( + ws: WebSocket, + code: string, + error: string, + sessionId?: string +): void { + sendJson(ws, { + kind: 'protocol_error', + code, + error, + sessionId: sessionId ?? null, + timestamp: new Date().toISOString(), + }); +} + +function readRequiredSessionId(data: AnyRecord): string | null { + const sessionId = typeof data.sessionId === 'string' ? data.sessionId.trim() : ''; + return sessionId.length > 0 ? sessionId : null; +} + +/** + * Handles `chat.send`: resolves the session row (provider, project path, and + * provider-native id all come from the database — never from the client), + * registers the run, and dispatches to the provider runtime. + */ +async function handleChatSend( + ws: WebSocket, + userId: string | number | null, + data: AnyRecord, + dependencies: ChatWebSocketDependencies +): Promise { + const sessionId = readRequiredSessionId(data); + if (!sessionId) { + sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.send requires a sessionId.'); + return; + } + + const session = sessionsDb.getSessionById(sessionId); + if (!session) { + sendProtocolError( + ws, + 'SESSION_NOT_FOUND', + `Session "${sessionId}" was not found. Create it via POST /api/providers/sessions first.`, + sessionId + ); + return; + } + + const provider = session.provider as LLMProvider; + const spawnFn = dependencies.spawnFns[provider]; + if (!spawnFn) { + sendProtocolError(ws, 'UNSUPPORTED_PROVIDER', `Provider "${provider}" is not available.`, sessionId); + return; + } + + const run = chatRunRegistry.startRun({ + appSessionId: sessionId, + provider, + providerSessionId: session.provider_session_id, + connection: ws, + userId, + }); + + if (!run) { + sendProtocolError( + ws, + 'RUN_IN_PROGRESS', + `Session "${sessionId}" already has a run in progress.`, + sessionId + ); + return; + } + + const clientOptions = (data.options ?? {}) as AnyRecord; + const command = typeof data.content === 'string' ? data.content : ''; + + // The provider runtimes receive the provider-native session id (that is the + // id their CLI/SDK understands for resume). Brand-new sessions have no + // provider id yet, so the runtime starts fresh and announces one, which the + // gateway writer captures and maps back to the app session id. + const runtimeOptions: AnyRecord = { + ...clientOptions, + sessionId: session.provider_session_id ?? undefined, + resume: Boolean(session.provider_session_id), + cwd: clientOptions.cwd ?? session.project_path ?? undefined, + projectPath: session.project_path ?? clientOptions.projectPath, + }; + + try { + await spawnFn(command, runtimeOptions, run.writer); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[Chat] Provider runtime "${provider}" failed`, { sessionId, error: message }); + } finally { + // Safety net: a runtime that crashed (or resolved) without emitting its + // terminal `complete` would otherwise leave the session stuck in + // "processing" forever on every connected client. + chatRunRegistry.completeRun(sessionId, { exitCode: 1 }); + } +} + +/** + * Handles `chat.abort`: cancels the run for one app session and emits the + * terminal `complete` on its behalf (runtimes skip their own complete for + * aborted runs, and the registry drops any duplicate). + */ +async function handleChatAbort( + ws: WebSocket, + data: AnyRecord, + dependencies: ChatWebSocketDependencies +): Promise { + const sessionId = readRequiredSessionId(data); + if (!sessionId) { + sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.abort requires a sessionId.'); + return; + } + + const run = chatRunRegistry.getRun(sessionId); + if (!run || run.status !== 'running') { + sendProtocolError(ws, 'NO_ACTIVE_RUN', `Session "${sessionId}" has no active run.`, sessionId); + return; + } + + const abortFn = dependencies.abortFns[run.provider]; + let success = false; + if (abortFn && run.providerSessionId) { + success = Boolean(await abortFn(run.providerSessionId)); + } + + chatRunRegistry.completeRun(sessionId, { + exitCode: success ? 0 : 1, + aborted: true, + }); +} + +/** + * Handles `chat.subscribe`: for each requested session, reports whether a run + * is processing, re-attaches the live stream to this socket, replays missed + * events (seq > lastSeq), and includes pending permission requests. + * + * This single message replaces the old `check-session-status`, + * `get-pending-permissions`, and Claude-only writer reconnect flows. + */ +function handleChatSubscribe( + ws: WebSocket, + data: AnyRecord, + dependencies: ChatWebSocketDependencies +): void { + const targets = Array.isArray(data.sessions) ? data.sessions : []; + + for (const target of targets) { + if (!target || typeof target !== 'object') { + continue; + } + + const sessionId = typeof (target as AnyRecord).sessionId === 'string' + ? ((target as AnyRecord).sessionId as string).trim() + : ''; + if (!sessionId) { + continue; + } + + const lastSeqRaw = (target as AnyRecord).lastSeq; + const lastSeq = typeof lastSeqRaw === 'number' && Number.isFinite(lastSeqRaw) + ? Math.max(0, Math.floor(lastSeqRaw)) + : 0; + + const run = chatRunRegistry.getRun(sessionId); + const isProcessing = chatRunRegistry.isProcessing(sessionId); + + // Future live events for this run should land on the socket that asked — + // this is what makes mid-stream page refreshes work for all providers. + if (isProcessing) { + chatRunRegistry.attachConnection(sessionId, ws); + } + + // Pending approvals are tracked under the provider-native id inside the + // Claude runtime; remap their sessionId so the client only sees app ids. + const pendingPermissions = (run?.providerSessionId + ? dependencies.getPendingApprovalsForSession(run.providerSessionId) + : [] + ).map((approval) => + approval && typeof approval === 'object' + ? { ...(approval as AnyRecord), sessionId } + : approval, + ); + + sendJson(ws, { + kind: 'chat_subscribed', + sessionId, + isProcessing, + lastSeq: run?.lastSeq ?? 0, + pendingPermissions, + timestamp: new Date().toISOString(), + }); + + // Replay only for RUNNING runs, strictly after the ack. Completed runs + // are fully persisted to the provider transcript and served over REST — + // replaying them (e.g. after a page reload where the client's lastSeq is + // 0) would duplicate messages the history fetch already returned. + if (isProcessing) { + for (const event of chatRunRegistry.replayEvents(sessionId, lastSeq)) { + sendJson(ws, event); + } + } + } +} + +/** + * Handles `chat.permission-response`: forwards a tool-approval decision to the + * pending approval resolver (Claude is the only provider with interactive + * approvals today, but the message is intentionally provider-neutral). + */ +function handlePermissionResponse(data: AnyRecord, dependencies: ChatWebSocketDependencies): void { + if (typeof data.requestId !== 'string' || data.requestId.length === 0) { + return; + } + + dependencies.resolveToolApproval(data.requestId, { + allow: Boolean(data.allow), + updatedInput: data.updatedInput, + message: typeof data.message === 'string' ? data.message : undefined, + rememberEntry: data.rememberEntry, + }); +} + /** * Handles authenticated chat websocket messages used by the main chat panel. + * + * Inbound protocol (client to server): + * - `chat.send` { sessionId, content, options? } + * - `chat.abort` { sessionId } + * - `chat.subscribe` { sessions: [{ sessionId, lastSeq? }] } + * - `chat.permission-response` { requestId, allow, updatedInput?, message?, rememberEntry? } + * + * Outbound protocol (server to client): every frame is `kind`-based — either + * a provider `NormalizedMessage` (with `seq`) or a gateway event + * (`chat_subscribed`, `session_upserted`, `loading_progress`, + * `protocol_error`). */ export function handleChatConnection( ws: WebSocket, @@ -103,7 +327,7 @@ export function handleChatConnection( console.log('[INFO] Chat WebSocket connected'); connectedClients.add(ws); - const writer = new WebSocketWriter(ws, readRequestUserId(request)); + const userId = readRequestUserId(request); ws.on('message', async (rawMessage) => { try { @@ -112,167 +336,30 @@ export function handleChatConnection( throw new Error('Invalid websocket payload'); } - const data = parsed as ChatIncomingMessage; - const messageType = data.type; - if (!messageType) { - throw new Error('Message type is required'); - } + const data = parsed as AnyRecord; + const messageType = typeof data.type === 'string' ? data.type : ''; - if (messageType === 'claude-command') { - await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'cursor-command') { - await dependencies.spawnCursor(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'codex-command') { - await dependencies.queryCodex(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'gemini-command') { - await dependencies.spawnGemini(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'opencode-command') { - await dependencies.spawnOpenCode(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'cursor-resume') { - await dependencies.spawnCursor( - '', - { - sessionId: data.sessionId, - resume: true, - cwd: data.options?.cwd, - }, - writer - ); - return; - } - - if (messageType === 'abort-session') { - const provider = readProvider(data.provider); - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; - let success = false; - - if (provider === 'cursor') { - success = dependencies.abortCursorSession(sessionId); - } else if (provider === 'codex') { - success = dependencies.abortCodexSession(sessionId); - } else if (provider === 'gemini') { - success = dependencies.abortGeminiSession(sessionId); - } else if (provider === 'opencode') { - success = dependencies.abortOpenCodeSession(sessionId); - } else { - success = await dependencies.abortClaudeSDKSession(sessionId); - } - - // Terminal complete on behalf of the cancelled run — providers skip - // their own complete for aborted runs so the client sees exactly one. - writer.send( - createCompleteMessage({ - provider, - sessionId, - exitCode: success ? 0 : 1, - aborted: true, - }) - ); - return; - } - - if (messageType === 'claude-permission-response') { - if (typeof data.requestId === 'string' && data.requestId.length > 0) { - dependencies.resolveToolApproval(data.requestId, { - allow: Boolean(data.allow), - updatedInput: data.updatedInput, - message: typeof data.message === 'string' ? data.message : undefined, - rememberEntry: data.rememberEntry, - }); - } - return; - } - - if (messageType === 'cursor-abort') { - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; - const success = dependencies.abortCursorSession(sessionId); - writer.send( - createCompleteMessage({ - provider: 'cursor', - sessionId, - exitCode: success ? 0 : 1, - aborted: true, - }) - ); - return; - } - - if (messageType === 'check-session-status') { - const provider = readProvider(data.provider); - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; - let isActive = false; - - if (provider === 'cursor') { - isActive = dependencies.isCursorSessionActive(sessionId); - } else if (provider === 'codex') { - isActive = dependencies.isCodexSessionActive(sessionId); - } else if (provider === 'gemini') { - isActive = dependencies.isGeminiSessionActive(sessionId); - } else if (provider === 'opencode') { - isActive = dependencies.isOpenCodeSessionActive(sessionId); - } else { - isActive = dependencies.isClaudeSDKSessionActive(sessionId); - if (isActive) { - dependencies.reconnectSessionWriter(sessionId, ws); - } - } - - writer.send({ - type: 'session-status', - sessionId, - provider, - isProcessing: isActive, - }); - return; - } - - if (messageType === 'get-pending-permissions') { - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; - if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) { - const pending = dependencies.getPendingApprovalsForSession(sessionId); - writer.send({ - type: 'pending-permissions-response', - sessionId, - data: pending, - }); - } - return; - } - - if (messageType === 'get-active-sessions') { - writer.send({ - type: 'active-sessions', - sessions: { - claude: dependencies.getActiveClaudeSDKSessions(), - cursor: dependencies.getActiveCursorSessions(), - codex: dependencies.getActiveCodexSessions(), - gemini: dependencies.getActiveGeminiSessions(), - opencode: dependencies.getActiveOpenCodeSessions(), - }, - }); + switch (messageType) { + case 'chat.send': + await handleChatSend(ws, userId, data, dependencies); + return; + case 'chat.abort': + await handleChatAbort(ws, data, dependencies); + return; + case 'chat.subscribe': + handleChatSubscribe(ws, data, dependencies); + return; + case 'chat.permission-response': + handlePermissionResponse(data, dependencies); + return; + default: + sendProtocolError(ws, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type "${messageType}".`); + return; } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error('[ERROR] Chat WebSocket error:', message); - writer.send({ - type: 'error', - error: message, - }); + sendProtocolError(ws, 'INTERNAL_ERROR', message); } }); diff --git a/server/modules/websocket/tests/chat-run-registry.test.ts b/server/modules/websocket/tests/chat-run-registry.test.ts new file mode 100644 index 00000000..e9a76df0 --- /dev/null +++ b/server/modules/websocket/tests/chat-run-registry.test.ts @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; +import type { NormalizedMessage } from '@/shared/types.js'; + +/** + * Minimal stand-in for a websocket connection: collects every JSON frame the + * gateway writer forwards so assertions can inspect the outbound protocol. + */ +class FakeConnection { + readyState = 1; // WS_OPEN_STATE + frames: NormalizedMessage[] = []; + + send(data: string): void { + this.frames.push(JSON.parse(data) as NormalizedMessage); + } +} + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'chat-run-registry-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + chatRunRegistry.clearAll(); + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('live events are remapped to the app session id and sequenced', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-1', 'claude', '/workspace/demo'); + const connection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-1', + provider: 'claude', + providerSessionId: null, + connection, + userId: 'user-1', + }); + assert.ok(run); + + run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'provider-id-9', content: 'hello' }); + run.writer.send({ kind: 'text', provider: 'claude', sessionId: 'provider-id-9', content: 'hello world' }); + + assert.equal(connection.frames.length, 2); + assert.equal(connection.frames[0]?.sessionId, 'app-run-1'); + assert.equal(connection.frames[0]?.seq, 1); + assert.equal(connection.frames[1]?.sessionId, 'app-run-1'); + assert.equal(connection.frames[1]?.seq, 2); + }); +}); + +test('session_created is swallowed and persisted as the provider-id mapping', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo'); + const connection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-2', + provider: 'cursor', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(run); + + run.writer.send({ + kind: 'session_created', + provider: 'cursor', + sessionId: 'cursor-native-7', + newSessionId: 'cursor-native-7', + }); + + // Never forwarded to the client... + assert.equal(connection.frames.length, 0); + // ...but recorded in the registry and persisted in the database. + assert.equal(run.providerSessionId, 'cursor-native-7'); + assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7'); + }); +}); + +test('complete marks the run finished and duplicate completes are dropped', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-3', 'codex', '/workspace/demo'); + const connection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-3', + provider: 'codex', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(run); + + run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 0 }); + // Late duplicate from a killed runtime's exit handler. + run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 1 }); + + const completes = connection.frames.filter((frame) => frame.kind === 'complete'); + assert.equal(completes.length, 1); + assert.equal(completes[0]?.actualSessionId, 'app-run-3'); + assert.equal(chatRunRegistry.isProcessing('app-run-3'), false); + + // completeRun is also a no-op once the run already completed. + chatRunRegistry.completeRun('app-run-3', { exitCode: 1 }); + assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1); + }); +}); + +test('replayEvents returns only events after the requested seq', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo'); + const connection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-4', + provider: 'claude', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(run); + + run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'a' }); + run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'b' }); + run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'c' }); + + const replayed = chatRunRegistry.replayEvents('app-run-4', 1); + assert.deepEqual(replayed.map((event) => event.content), ['b', 'c']); + assert.deepEqual(replayed.map((event) => event.seq), [2, 3]); + }); +}); + +test('attachConnection reroutes the live stream to a new socket', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo'); + const firstConnection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-5', + provider: 'gemini', + providerSessionId: null, + connection: firstConnection, + userId: null, + }); + assert.ok(run); + + run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' }); + + const secondConnection = new FakeConnection(); + assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true); + run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' }); + + assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']); + assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']); + }); +}); + +test('startRun rejects a second concurrent run for the same session', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-6', 'opencode', '/workspace/demo'); + const connection = new FakeConnection(); + const first = chatRunRegistry.startRun({ + appSessionId: 'app-run-6', + provider: 'opencode', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(first); + + const second = chatRunRegistry.startRun({ + appSessionId: 'app-run-6', + provider: 'opencode', + providerSessionId: null, + connection, + userId: null, + }); + assert.equal(second, null); + + // After the run finishes a new one is allowed again. + chatRunRegistry.completeRun('app-run-6', { exitCode: 0 }); + const third = chatRunRegistry.startRun({ + appSessionId: 'app-run-6', + provider: 'opencode', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(third); + }); +}); diff --git a/server/shared/tests/slice-tail-page.test.ts b/server/shared/tests/slice-tail-page.test.ts new file mode 100644 index 00000000..e4d17db2 --- /dev/null +++ b/server/shared/tests/slice-tail-page.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { sliceTailPage } from '@/shared/utils.js'; + +const ITEMS = ['a', 'b', 'c', 'd', 'e']; + +test('offset 0 returns the most recent page', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 2, 0); + assert.deepEqual(page, ['d', 'e']); + assert.equal(hasMore, true); +}); + +test('increasing offsets walk backwards in time', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 2, 2); + assert.deepEqual(page, ['b', 'c']); + assert.equal(hasMore, true); +}); + +test('the oldest page reports hasMore false', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 2, 4); + assert.deepEqual(page, ['a']); + assert.equal(hasMore, false); +}); + +test('null limit returns everything', () => { + const { page, hasMore } = sliceTailPage(ITEMS, null, 0); + assert.deepEqual(page, ITEMS); + assert.equal(hasMore, false); +}); + +test('offsets past the start return an empty page', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 3, 10); + assert.deepEqual(page, []); + assert.equal(hasMore, false); +}); + +test('zero limit returns an empty page but keeps hasMore accurate', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 0, 0); + assert.deepEqual(page, []); + assert.equal(hasMore, true); +}); diff --git a/server/shared/types.ts b/server/shared/types.ts index de8b16c0..91c477a6 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -175,6 +175,30 @@ export type MessageKind = | 'interactive_prompt' | 'task_notification'; +/** + * Event kinds added by the chat gateway layer on top of provider message kinds. + * + * These are app-level realtime events (subscription acks, sidebar deltas, + * project loading progress, protocol failures) that are not produced by any + * provider adapter. Together with `MessageKind` they form the complete set of + * `kind` values a websocket client can receive, so the frontend only ever + * needs one kind-based switch. + */ +export type GatewayEventKind = + | 'chat_subscribed' + | 'session_upserted' + | 'loading_progress' + | 'protocol_error'; + +/** + * Complete set of `kind` values emitted to websocket clients. + * + * Every server-to-client websocket frame carries a `kind` from this union. + * Provider runtimes emit `MessageKind` values; gateway services emit + * `GatewayEventKind` values. + */ +export type ServerEventKind = MessageKind | GatewayEventKind; + /** * Provider-neutral message envelope used in REST responses and realtime channels. * @@ -187,6 +211,13 @@ export type NormalizedMessage = { timestamp: string; provider: LLMProvider; kind: MessageKind; + /** + * Monotonic per-run sequence number assigned by the chat run registry when a + * live event is forwarded to the websocket. History messages loaded over + * REST do not carry it. Clients use it with `chat.subscribe` to replay only + * the live events they missed across websocket reconnects. + */ + seq?: number; role?: 'user' | 'assistant'; content?: string; /** @@ -237,11 +268,18 @@ export type NormalizedMessage = { * * Consumers should pass provider-specific lookup hints (`projectPath`) only * when the selected provider requires them. + * + * `providerSessionId` is the provider-native session id from the sessions + * index (transcript file name / provider database key). Provider adapters + * must use it — never the app-facing session id they were called with — when + * matching transcript rows on disk, because app-created sessions use an + * app-allocated id that the provider has never seen. */ export type FetchHistoryOptions = { projectPath?: string; limit?: number | null; offset?: number; + providerSessionId?: string; }; /** diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 78888ae2..65fd4c22 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -383,6 +383,47 @@ export function createCompleteMessage(opts: { }); } +// --------------------------- +//----------------- CONVERSATION HISTORY PAGINATION UTILITIES ------------ +/** + * Slices one page from the END of a chronologically ordered message list. + * + * This is the single pagination contract for conversation history across all + * providers: `offset = 0` returns the most recent `limit` items, increasing + * offsets walk backwards in time (for "scroll up to load older" UIs), and a + * `null` limit returns everything. Items must already be sorted oldest-first; + * the returned page preserves that order. + * + * Every provider history reader must use this helper instead of slicing + * manually so `offset`/`limit` query params behave identically regardless of + * which provider produced the session. + */ +export function sliceTailPage( + items: T[], + limit: number | null, + offset: number, +): { page: T[]; hasMore: boolean } { + const total = items.length; + const normalizedOffset = Math.max(0, offset); + + if (limit === null) { + // A null limit returns the full list; offset still trims newest entries + // so "everything before the page I already have" stays expressible. + const end = Math.max(0, total - normalizedOffset); + return { + page: items.slice(0, end), + hasMore: false, + }; + } + + const end = Math.max(0, total - normalizedOffset); + const start = Math.max(0, end - Math.max(0, limit)); + return { + page: items.slice(start, end), + hasMore: start > 0, + }; +} + // --------------------------- //----------------- MCP CONFIG PARSING UTILITIES ------------ /** diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 9e5a6ac2..0e39c956 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -24,8 +24,7 @@ function AppContentInner() { const { sessionId } = useParams<{ sessionId?: string }>(); const { t } = useTranslation('common'); const { isMobile } = useDeviceSettings({ trackPWA: false }); - const { ws, sendMessage, latestMessage, isConnected } = useWebSocket(); - const wasConnectedRef = useRef(false); + const { ws, sendMessage, subscribe } = useWebSocket(); const { processingSessions, @@ -52,7 +51,7 @@ function AppContentInner() { } = useProjectsState({ sessionId, navigate, - latestMessage, + subscribe, isMobile, activeSessions: processingSessions, }); @@ -96,23 +95,9 @@ function AppContentInner() { }; }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]); - // Permission recovery: query pending permissions on WebSocket reconnect or session change - useEffect(() => { - const isReconnect = isConnected && !wasConnectedRef.current; - - if (isReconnect) { - wasConnectedRef.current = true; - } else if (!isConnected) { - wasConnectedRef.current = false; - } - - if (isConnected && selectedSession?.id) { - sendMessage({ - type: 'get-pending-permissions', - sessionId: selectedSession.id - }); - } - }, [isConnected, selectedSession?.id, sendMessage]); + // Pending tool permissions are recovered through the `chat.subscribe` flow: + // the `chat_subscribed` ack carries them on session open and on reconnect, + // so no separate permission-recovery message is needed here. // Adjust the app container to stay above the virtual keyboard on iOS Safari. // On Chrome for Android the layout viewport already shrinks when the keyboard opens, @@ -177,7 +162,6 @@ function AppContentInner() { setActiveTab={setActiveTab} ws={ws} sendMessage={sendMessage} - latestMessage={latestMessage} isMobile={isMobile} onMenuClick={() => setSidebarOpen(true)} isLoading={isLoadingProjects} diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 81cd97e4..ed0a4962 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -12,7 +12,6 @@ import type { import { useDropzone } from 'react-dropzone'; import { authenticatedFetch } from '../../../utils/api'; -import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection'; import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import { grantClaudeToolPermission } from '../utils/chatPermissions'; import { safeLocalStorage } from '../utils/chatStorage'; @@ -45,6 +44,14 @@ interface UseChatComposerStateArgs { sendMessage: (message: unknown) => void; sendByCtrlEnter?: boolean; onSessionProcessing?: MarkSessionProcessing; + /** + * Invoked with the freshly allocated session id when the user sends the + * first message of a brand-new conversation. The backend allocates the id + * via POST /api/providers/sessions BEFORE the websocket send, so the id is + * stable for the conversation's whole lifetime — the consumer navigates to + * /session/:id and records it as the current session. + */ + onSessionEstablished?: (sessionId: string) => void; onInputFocusChange?: (focused: boolean) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; @@ -171,6 +178,7 @@ export function useChatComposerState({ sendMessage, sendByCtrlEnter, onSessionProcessing, + onSessionEstablished, onInputFocusChange, onFileOpen, onShowSettings, @@ -597,8 +605,49 @@ export function useChatComposerState({ } } - const effectiveSessionId = - currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); + const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; + + // The conversation always has a stable backend-allocated session id + // BEFORE the first websocket send: brand-new chats allocate one here + // via the session gateway. There is no client-visible session-id + // handoff later — this id stays valid for the conversation's lifetime. + let targetSessionId = selectedSession?.id || currentSessionId || null; + if (!targetSessionId) { + try { + const response = await authenticatedFetch('/api/providers/sessions', { + method: 'POST', + body: JSON.stringify({ + provider, + projectPath: resolvedProjectPath, + }), + }); + if (!response.ok) { + throw new Error(`Failed to create session (${response.status})`); + } + const body = await response.json(); + targetSessionId = body?.data?.sessionId || null; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('Session creation failed:', error); + addMessage({ + type: 'error', + content: `Failed to start a new session: ${message}`, + timestamp: new Date(), + }); + return; + } + + if (!targetSessionId) { + addMessage({ + type: 'error', + content: 'Failed to start a new session: no session id returned.', + timestamp: new Date(), + }); + return; + } + + onSessionEstablished?.(targetSessionId); + } const userMessage: ChatMessage = { type: 'user', @@ -609,10 +658,9 @@ export function useChatComposerState({ addMessage(userMessage); // Mark this request as processing in the per-session activity map (the - // single source of truth the indicator derives from). A brand-new - // conversation has no session id yet, so it is tracked under the - // pending placeholder until `session_created` announces the real id. - onSessionProcessing?.(effectiveSessionId || PENDING_SESSION_ID, { + // single source of truth the indicator derives from). The id is always + // concrete at this point — no pending placeholder exists anymore. + onSessionProcessing?.(targetSessionId, { statusText: null, canInterrupt: true, }); @@ -648,87 +696,37 @@ export function useChatComposerState({ }; const toolsSettings = getToolsSettings(); - const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); - if (provider === 'cursor') { - sendMessage({ - type: 'cursor-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: resolvedProjectPath, - projectPath: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - model: cursorModel, - skipPermissions: toolsSettings?.skipPermissions || false, - sessionSummary, - toolsSettings, - }, - }); - } else if (provider === 'codex') { - sendMessage({ - type: 'codex-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: resolvedProjectPath, - projectPath: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - model: codexModel, - sessionSummary, - permissionMode: permissionMode === 'plan' ? 'default' : permissionMode, - }, - }); - } else if (provider === 'gemini') { - sendMessage({ - type: 'gemini-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: resolvedProjectPath, - projectPath: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - model: geminiModel, - sessionSummary, - permissionMode, - toolsSettings, - }, - }); - } else if (provider === 'opencode') { - sendMessage({ - type: 'opencode-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: resolvedProjectPath, - projectPath: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - model: opencodeModel, - sessionSummary, - }, - }); - } else { - sendMessage({ - type: 'claude-command', - command: messageContent, - options: { - projectPath: resolvedProjectPath, - cwd: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - toolsSettings, - permissionMode, - model: claudeModel, - sessionSummary, - images: uploadedImages, - }, - }); - } + const model = + provider === 'cursor' + ? cursorModel + : provider === 'codex' + ? codexModel + : provider === 'gemini' + ? geminiModel + : provider === 'opencode' + ? opencodeModel + : claudeModel; + + // One message shape for every provider. The backend resolves the + // provider, project path, and provider-native resume id from the + // session row; `options` only carries composer-level preferences. + sendMessage({ + type: 'chat.send', + sessionId: targetSessionId, + content: messageContent, + options: { + model, + // Codex has no plan mode; downgrade rather than sending an + // unsupported value to its runtime. + permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode, + toolsSettings, + skipPermissions: toolsSettings?.skipPermissions || false, + sessionSummary, + images: uploadedImages, + }, + }); setInput(''); inputValueRef.current = ''; @@ -756,6 +754,7 @@ export function useChatComposerState({ opencodeModel, isLoading, onSessionProcessing, + onSessionEstablished, permissionMode, provider, resetCommandMenuState, @@ -918,29 +917,19 @@ export function useChatComposerState({ return; } - const cursorSessionId = - typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null; - - const candidateSessionIds = [ - currentSessionId, - provider === 'cursor' ? cursorSessionId : null, - selectedSession?.id || null, - ]; - - const targetSessionId = - candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null; - + const targetSessionId = selectedSession?.id || currentSessionId || null; if (!targetSessionId) { - console.warn('Abort requested but no concrete session ID is available yet.'); + console.warn('Abort requested but no session ID is available.'); return; } + // The backend resolves the provider from the session row, so no provider + // field is needed here. sendMessage({ - type: 'abort-session', + type: 'chat.abort', sessionId: targetSessionId, - provider, }); - }, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]); + }, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]); const handleGrantToolPermission = useCallback( (suggestion: { entry: string; toolName: string }) => { @@ -965,7 +954,7 @@ export function useChatComposerState({ validIds.forEach((requestId) => { sendMessage({ - type: 'claude-permission-response', + type: 'chat.permission-response', requestId, allow: Boolean(decision?.allow), updatedInput: decision?.updatedInput, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 33c54d4b..a2910b0d 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -17,17 +17,35 @@ const FALLBACK_DEFAULT_MODEL: Record = { opencode: 'anthropic/claude-sonnet-4-5', }; -const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => { - if (provider === 'codex') { - return ['default', 'acceptEdits', 'bypassPermissions']; - } - if (provider === 'claude') { - return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan']; - } - if (provider === 'opencode') { - return ['default']; - } - return ['default', 'acceptEdits', 'bypassPermissions', 'plan']; +/** + * Fallback permission-mode matrix used only until the backend capability + * matrix (`GET /api/providers/capabilities`) has loaded. The backend is the + * source of truth; this mirror exists so the composer renders sensibly on + * first paint and when the capabilities request fails. + */ +const FALLBACK_PERMISSION_MODES: Record = { + claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'], + cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + codex: ['default', 'acceptEdits', 'bypassPermissions'], + gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + opencode: ['default'], +}; + +type ProviderCapabilities = { + provider: LLMProvider; + permissionModes: string[]; + defaultPermissionMode: string; + supportsImages: boolean; + supportsAbort: boolean; + supportsPermissionRequests: boolean; + supportsTokenUsage: boolean; +}; + +type ProviderCapabilitiesApiResponse = { + success?: boolean; + data?: { + providers?: ProviderCapabilities[]; + }; }; interface UseChatProviderStateArgs { @@ -76,6 +94,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode; }); + /** + * Backend-owned capability matrix keyed by provider. Drives the permission + * mode picker (and is the extension point for future per-provider UI + * differences) so the frontend stays free of hardcoded provider branching. + * Null until `/api/providers/capabilities` resolves; the static fallback + * map covers that window. + */ + const [providerCapabilities, setProviderCapabilities] = useState< + Partial> | null + >(null); + const [providerModelCatalog, setProviderModelCatalog] = useState< Partial> >({}); @@ -181,6 +210,41 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh void loadProviderModels(); }, [loadProviderModels]); + useEffect(() => { + let cancelled = false; + + const loadCapabilities = async () => { + try { + const response = await authenticatedFetch('/api/providers/capabilities'); + const body = (await response.json()) as ProviderCapabilitiesApiResponse; + if (cancelled || !body.success || !Array.isArray(body.data?.providers)) { + return; + } + + const byProvider: Partial> = {}; + for (const capabilities of body.data.providers) { + byProvider[capabilities.provider] = capabilities; + } + setProviderCapabilities(byProvider); + } catch (error) { + console.error('Error loading provider capabilities:', error); + } + }; + + void loadCapabilities(); + return () => { + cancelled = true; + }; + }, []); + + const getPermissionModesForProvider = useCallback((targetProvider: LLMProvider): PermissionMode[] => { + const capabilityModes = providerCapabilities?.[targetProvider]?.permissionModes; + if (capabilityModes && capabilityModes.length > 0) { + return capabilityModes as PermissionMode[]; + } + return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default']; + }, [providerCapabilities]); + const pickStoredOrCurrent = ( storageKey: string, current: string, @@ -269,7 +333,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null; const validModes = getPermissionModesForProvider(provider); setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default'); - }, [selectedSession?.id, provider]); + }, [selectedSession?.id, provider, getPermissionModesForProvider]); useEffect(() => { if (!selectedSession?.__provider || selectedSession.__provider === provider) { @@ -327,7 +391,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh if (selectedSession?.id) { localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode); } - }, [permissionMode, provider, selectedSession?.id]); + }, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]); const selectProviderModel = useCallback(async ( targetProvider: LLMProvider, diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 816576af..be741c7e 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,67 +1,34 @@ -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; -import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; +import type { ServerEvent } from '../../../contexts/WebSocketContext'; import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification'; import { playChatCompletionSound } from '../../../utils/notificationSound'; -import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection'; import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection'; -import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types'; +import type { PendingPermissionRequest } from '../types/types'; import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; -type LatestChatMessage = { - type?: string; - kind?: string; - data?: any; - message?: any; - delta?: string; - sessionId?: string; - session_id?: string; - requestId?: string; - toolName?: string; - input?: unknown; - context?: unknown; - error?: string; - tool?: any; - toolId?: string; - result?: any; - exitCode?: number; - isProcessing?: boolean; - actualSessionId?: string; - event?: string; - status?: any; - isNewSession?: boolean; - resultText?: string; - isError?: boolean; - success?: boolean; - reason?: string; - provider?: string; - content?: string; - text?: string; - tokens?: number; - canInterrupt?: boolean; - tokenBudget?: unknown; - newSessionId?: string; - aborted?: boolean; - [key: string]: any; -}; - interface UseChatRealtimeHandlersArgs { - latestMessage: LatestChatMessage | null; + subscribe: (listener: (event: ServerEvent) => void) => () => void; provider: LLMProvider; selectedSession: ProjectSession | null; currentSessionId: string | null; - setCurrentSessionId: (sessionId: string | null) => void; setTokenBudget: (budget: Record | null) => void; setPendingPermissionRequests: Dispatch>; streamTimerRef: MutableRefObject; accumulatedStreamRef: MutableRefObject; - /** When each session's `check-session-status` was last sent; guards stale idle replies. */ + /** + * Highest live `seq` observed per session. Essential for reconnect catch-up: + * `chat.subscribe` sends this value as `lastSeq` so the server replays only + * the events this client actually missed. Written here on every sequenced + * frame; read wherever a `chat.subscribe` is sent (session open, reconnect). + */ + lastSeqRef: MutableRefObject>; + /** When each session's `chat.subscribe` was last sent; guards stale idle acks. */ statusCheckSentAtRef: MutableRefObject>; onSessionProcessing?: MarkSessionProcessing; onSessionIdle?: MarkSessionIdle; - onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void; onWebSocketReconnect?: () => void; sessionStore: SessionStore; } @@ -70,293 +37,259 @@ interface UseChatRealtimeHandlersArgs { /* Hook */ /* ------------------------------------------------------------------ */ +/** + * Routes server events into the session store and processing-state map. + * + * This is intentionally a thin reducer over the unified `kind`-based + * protocol: every frame is keyed by the stable app session id, so there is + * no session-id handoff, no provider branching, and no navigation here. + * Sidebar events (`session_upserted`, `loading_progress`) are handled by + * `useProjectsState`, not in this hook. + */ export function useChatRealtimeHandlers({ - latestMessage, + subscribe, provider, selectedSession, currentSessionId, - setCurrentSessionId, setTokenBudget, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, + lastSeqRef, statusCheckSentAtRef, onSessionProcessing, onSessionIdle, - onNavigateToSession, onWebSocketReconnect, sessionStore, }: UseChatRealtimeHandlersArgs) { - const paletteOps = usePaletteOps(); - const lastProcessedMessageRef = useRef(null); - useEffect(() => { - if (!latestMessage) return; - if (lastProcessedMessageRef.current === latestMessage) return; - lastProcessedMessageRef.current = latestMessage; + const handleEvent = (msg: ServerEvent) => { + if (!msg.kind) { + return; + } - const activeViewSessionId = - selectedSession?.id || currentSessionId || null; + const activeViewSessionId = selectedSession?.id || currentSessionId || null; + const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId; - /* ---------------------------------------------------------------- */ - /* Legacy messages (no `kind` field) — handle and return */ - /* ---------------------------------------------------------------- */ + // Record replay progress for every sequenced live event. + if (sid && typeof msg.seq === 'number') { + const known = lastSeqRef.current.get(sid) ?? 0; + if (msg.seq > known) { + lastSeqRef.current.set(sid, msg.seq); + } + } - const msg = latestMessage as any; - - if (!msg.kind) { - const messageType = String(msg.type || ''); - - switch (messageType) { - case 'websocket-reconnected': + switch (msg.kind) { + case 'websocket_reconnected': onWebSocketReconnect?.(); return; - case 'pending-permissions-response': { - const permSessionId = msg.sessionId; - const isCurrentPermSession = - permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id); - if (permSessionId && !isCurrentPermSession) return; - setPendingPermissionRequests(msg.data || []); - return; - } + case 'chat_subscribed': { + // Ack for chat.subscribe: authoritative processing state plus any + // pending tool-permission prompts for the run. + if (!sid) return; - case 'session-status': { - const statusSessionId = msg.sessionId; - if (!statusSessionId) return; - - const status = msg.status; - if (status) { - onSessionProcessing?.(statusSessionId, { - statusText: status.text || null, - canInterrupt: status.can_interrupt !== false, - }); - return; - } - - // Reply to check-session-status (or unsolicited processing update) if (msg.isProcessing) { - onSessionProcessing?.(statusSessionId); - return; + onSessionProcessing?.(sid); + } else { + // Idle ack: ignore it if a newer request started after the + // subscribe was sent — the ack describes the older state. + onSessionIdle?.(sid, { + ifStartedBefore: statusCheckSentAtRef.current.get(sid), + }); } - // Idle reply: ignore it if a newer request started after the check - // was sent — the reply describes the older request. - onSessionIdle?.(statusSessionId, { - ifStartedBefore: statusCheckSentAtRef.current.get(statusSessionId), - }); + const isViewedSession = sid === activeViewSessionId; + if (isViewedSession && Array.isArray(msg.pendingPermissions)) { + setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]); + } return; } + case 'protocol_error': { + console.error('[Chat] Protocol error:', msg.code, msg.error); + if (sid) { + // Surface the failure in the conversation and stop the spinner — + // the run never started (or was rejected), so no `complete` follows. + onSessionIdle?.(sid); + sessionStore.appendRealtime(sid, { + id: `protocol_error_${Date.now()}`, + sessionId: sid, + timestamp: new Date().toISOString(), + provider, + kind: 'error', + content: String(msg.error || 'Request failed'), + } as NormalizedMessage); + } + return; + } + + // Sidebar/global events — owned by useProjectsState. + case 'session_upserted': + case 'loading_progress': + return; + default: - // Unknown legacy message type — ignore - return; + break; } - } - /* ---------------------------------------------------------------- */ - /* NormalizedMessage handling (has `kind` field) */ - /* ---------------------------------------------------------------- */ + /* -------------------------------------------------------------- */ + /* Provider NormalizedMessage handling */ + /* -------------------------------------------------------------- */ - const sid = msg.sessionId || activeViewSessionId; - - // --- Streaming: buffer for performance --- - if (msg.kind === 'stream_delta') { - const text = msg.content || ''; - if (!text) return; - accumulatedStreamRef.current += text; - if (!streamTimerRef.current) { - streamTimerRef.current = window.setTimeout(() => { - streamTimerRef.current = null; - if (sid) { - sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); - } - }, 100); - } - // Also route to store for non-active sessions - if (sid && sid !== activeViewSessionId) { - sessionStore.appendRealtime(sid, msg as NormalizedMessage); - } - return; - } - - if (msg.kind === 'stream_end') { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - if (sid) { - if (accumulatedStreamRef.current) { - sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + // --- Streaming: buffer for performance --- + if (msg.kind === 'stream_delta') { + const text = (msg.content as string) || ''; + if (!text) return; + accumulatedStreamRef.current += text; + if (!streamTimerRef.current) { + streamTimerRef.current = window.setTimeout(() => { + streamTimerRef.current = null; + if (sid) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + } + }, 100); } - sessionStore.finalizeStreaming(sid); - } - accumulatedStreamRef.current = ''; - return; - } - - // --- All other messages: route to store --- - const shouldPersist = - msg.kind !== 'session_created' - && msg.kind !== 'complete' - && msg.kind !== 'status' - && msg.kind !== 'permission_request' - && msg.kind !== 'permission_cancelled'; - - if (sid && shouldPersist) { - sessionStore.appendRealtime(sid, msg as NormalizedMessage); - } - - // --- UI side effects for specific kinds --- - switch (msg.kind) { - case 'session_created': { - const newSessionId = msg.newSessionId; - if (!newSessionId) break; - - // We no longer synthesize client-side placeholder IDs. Until the provider - // announces `session_created`, the active id is expected to be null. - if (!currentSessionId) { - setCurrentSessionId(newSessionId); - setPendingPermissionRequests((prev) => - prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })), - ); + // Also route to store for non-active sessions + if (sid && sid !== activeViewSessionId) { + sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage); } - // The in-flight request now has a concrete session id: migrate the - // processing entry from the pending placeholder. - onSessionIdle?.(PENDING_SESSION_ID); - onSessionProcessing?.(newSessionId); - onNavigateToSession?.(newSessionId); - break; + return; } - case 'complete': { - // Flush any remaining streaming state + if (msg.kind === 'stream_end') { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } - if (sid && accumulatedStreamRef.current) { - sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + if (sid) { + if (accumulatedStreamRef.current) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + } sessionStore.finalizeStreaming(sid); } accumulatedStreamRef.current = ''; + return; + } - // `complete` is the unified terminal event — every provider run ends - // with exactly one, regardless of success, failure, or abort. The - // indicator derives from the processing map, so deleting the entry - // hides it immediately and atomically. - onSessionIdle?.(sid); - onSessionIdle?.(PENDING_SESSION_ID); - setPendingPermissionRequests([]); + // --- All other messages: route to store --- + const shouldPersist = + msg.kind !== 'complete' + && msg.kind !== 'status' + && msg.kind !== 'permission_request' + && msg.kind !== 'permission_cancelled'; + + if (sid && shouldPersist) { + sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage); + } + + // --- UI side effects for specific kinds --- + switch (msg.kind) { + case 'complete': { + // Flush any remaining streaming state + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current); + streamTimerRef.current = null; + } + if (sid && accumulatedStreamRef.current) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + sessionStore.finalizeStreaming(sid); + } + accumulatedStreamRef.current = ''; + + // `complete` is the unified terminal event — every provider run ends + // with exactly one, regardless of success, failure, or abort. The + // indicator derives from the processing map, so deleting the entry + // hides it immediately and atomically. + onSessionIdle?.(sid); + setPendingPermissionRequests([]); + + if (msg.aborted) { + // Abort was requested — the complete event confirms it. No + // further UI action is needed beyond clearing the entry above. + break; + } + + // Celebrate only successful runs (failed runs end with success: false). + if (msg.success !== false) { + showCompletionTitleIndicator(); + void playChatCompletionSound(); + } + + // The session id is stable for the whole conversation (allocated + // before the first send), so the only follow-up is syncing the + // viewed conversation with the now-persisted transcript. + if (sid && sid === activeViewSessionId) { + void sessionStore.refreshFromServer(sid); + } - // Handle aborted case - if (msg.aborted) { - // Abort was requested — the complete event confirms it - // No special UI action needed beyond clearing the processing entry above - // The backend already sent any abort-related messages break; } - // Celebrate only successful runs (failed runs end with success: false). - if (msg.success !== false) { - showCompletionTitleIndicator(); - void playChatCompletionSound(); - } + // 'error' is an informational message row, not a terminal event — + // providers emit it for mid-run stderr output too. Run teardown is + // always signalled by the unified 'complete' that follows. - const actualSessionId = - typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0 - ? msg.actualSessionId - : null; - const isVisibleSession = - Boolean( - sid - && sid === activeViewSessionId, - ); - - if (actualSessionId && sid && actualSessionId !== sid) { - sessionStore.replaceSessionId(sid, actualSessionId); - onSessionIdle?.(actualSessionId); - - if (isVisibleSession) { - setCurrentSessionId(actualSessionId); - void sessionStore.refreshFromServer(actualSessionId); - } - - if (isVisibleSession) { - onNavigateToSession?.(actualSessionId, { replace: true }); - setTimeout(() => { void paletteOps.refreshProjects(); }, 500); - } - break; - } - - if (sid && isVisibleSession) { - void sessionStore.refreshFromServer(sid); - } - - break; - } - - // 'error' is an informational message row, not a terminal event — - // providers emit it for mid-run stderr output too. Run teardown is - // always signalled by the unified 'complete' that follows. - - case 'permission_request': { - if (!msg.requestId) break; - setPendingPermissionRequests((prev) => { - if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; - return [...prev, { - requestId: msg.requestId, - toolName: msg.toolName || 'UnknownTool', - input: msg.input, - context: msg.context, - sessionId: sid || null, - receivedAt: new Date(), - }]; - }); - onSessionProcessing?.(sid || PENDING_SESSION_ID); - break; - } - - case 'permission_cancelled': { - if (msg.requestId) { - setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId)); - } - break; - } - - case 'status': { - if (msg.text === 'token_budget' && msg.tokenBudget) { - setTokenBudget(msg.tokenBudget as Record); - } else if (msg.text) { - onSessionProcessing?.(sid || PENDING_SESSION_ID, { - statusText: msg.text, - canInterrupt: msg.canInterrupt !== false, + case 'permission_request': { + if (!msg.requestId) break; + setPendingPermissionRequests((prev) => { + if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; + return [...prev, { + requestId: msg.requestId as string, + toolName: (msg.toolName as string) || 'UnknownTool', + input: msg.input, + context: msg.context, + sessionId: sid || null, + receivedAt: new Date(), + }]; }); + if (sid) { + onSessionProcessing?.(sid); + } + break; } - break; - } - // text, tool_use, tool_result, thinking, interactive_prompt, task_notification - // → already routed to store above, no UI side effects needed - default: - break; - } + case 'permission_cancelled': { + if (msg.requestId) { + setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId)); + } + break; + } + + case 'status': { + if (msg.text === 'token_budget' && msg.tokenBudget) { + setTokenBudget(msg.tokenBudget as Record); + } else if (msg.text && sid) { + onSessionProcessing?.(sid, { + statusText: msg.text as string, + canInterrupt: msg.canInterrupt !== false, + }); + } + break; + } + + // text, tool_use, tool_result, thinking, interactive_prompt, task_notification + // → already routed to store above, no UI side effects needed + default: + break; + } + }; + + return subscribe(handleEvent); }, [ - latestMessage, + subscribe, provider, selectedSession, currentSessionId, - setCurrentSessionId, setTokenBudget, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, + lastSeqRef, statusCheckSentAtRef, onSessionProcessing, onSessionIdle, - onNavigateToSession, onWebSocketReconnect, sessionStore, - paletteOps, ]); } diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 69268f61..a7844975 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import type { MutableRefObject } from 'react'; import { authenticatedFetch } from '../../../utils/api'; -import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection'; import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; @@ -25,8 +24,10 @@ interface UseChatSessionStateArgs { processingSessions?: SessionActivityMap; onSessionIdle?: MarkSessionIdle; resetStreamingState: () => void; - /** When each session's `check-session-status` was last sent; guards stale idle replies. */ + /** When each session's `chat.subscribe` was last sent; guards stale idle acks. */ statusCheckSentAtRef: MutableRefObject>; + /** Highest live seq observed per session; sent as `lastSeq` on subscribe. */ + lastSeqRef: MutableRefObject>; sessionStore: SessionStore; } @@ -102,6 +103,7 @@ export function useChatSessionState({ onSessionIdle, resetStreamingState, statusCheckSentAtRef, + lastSeqRef, sessionStore, }: UseChatSessionStateArgs) { const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); @@ -168,10 +170,8 @@ export function useChatSessionState({ * - No coupling to unrelated external update signals. */ resetStreamingState(); - onSessionIdle?.(PENDING_SESSION_ID); setCurrentSessionId(null); setPendingUserMessage(null); - sessionStorage.removeItem('cursorSessionId'); messagesOffsetRef.current = 0; setHasMoreMessages(false); setTotalMessages(0); @@ -208,9 +208,10 @@ export function useChatSessionState({ const activeSessionId = selectedSession?.id || currentSessionId || null; // The activity indicator always reflects the latest status of the session - // being viewed (or of the pending not-yet-created session on a fresh - // draft) — never stale local UI state from the last time it was open. - const sessionActivity = processingSessions?.get(activeSessionId ?? PENDING_SESSION_ID) ?? null; + // being viewed — never stale local UI state from the last time it was + // open. Session ids are concrete before any send, so no pending + // placeholder entry exists anymore. + const sessionActivity = (activeSessionId && processingSessions?.get(activeSessionId)) || null; const isProcessing = sessionActivity !== null; const canAbortSession = isProcessing && sessionActivity.canInterrupt; @@ -440,15 +441,15 @@ export function useChatSessionState({ // Main session loading effect — store-based useEffect(() => { if (!selectedSession || !selectedProject) { - // A new provider run can be in flight before the router has a canonical - // selectedSession. Keep the draft view intact until complete/error. - if (processingSessionsRef.current?.has(PENDING_SESSION_ID)) { + // A freshly created session can be mid-run before the router has a + // canonical selectedSession (the URL effect synthesizes one on the + // next render). Keep the active view intact instead of wiping it. + if (currentSessionId && processingSessionsRef.current?.has(currentSessionId)) { return; } resetStreamingState(); setCurrentSessionId(null); - sessionStorage.removeItem('cursorSessionId'); messagesOffsetRef.current = 0; setHasMoreMessages(false); setTotalMessages(0); @@ -489,16 +490,21 @@ export function useChatSessionState({ } setCurrentSessionId(selectedSession.id); - if (provider === 'cursor') { - sessionStorage.setItem('cursorSessionId', selectedSession.id); - } - // Reconcile processing state with the server. Recording the send time - // lets the reply handler discard idle replies that a newer request has + // Subscribe to the session's live run (if any): the ack reconciles the + // processing indicator, re-attaches a mid-flight stream to this socket, + // and replays any live events missed since `lastSeq`. Recording the send + // time lets the ack handler discard idle acks that a newer request has // since outdated. if (ws) { statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); - sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider }); + sendMessage({ + type: 'chat.subscribe', + sessions: [{ + sessionId: selectedSession.id, + lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0, + }], + }); } lastLoadedSessionKeyRef.current = sessionKey; @@ -527,6 +533,7 @@ export function useChatSessionState({ selectedSession?.id, sendMessage, statusCheckSentAtRef, + lastSeqRef, ws, sessionStore, ]); diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index bfede588..a8cde669 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -112,7 +112,6 @@ export interface ChatInterfaceProps { selectedSession: ProjectSession | null; ws: WebSocket | null; sendMessage: (message: unknown) => void; - latestMessage: any; onFileOpen?: (filePath: string, diffInfo?: any) => void; onInputFocusChange?: (focused: boolean) => void; onSessionProcessing?: MarkSessionProcessing; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index c4b46391..7346f1c2 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; +import { useWebSocket } from '../../../contexts/WebSocketContext'; import PermissionContext from '../../../contexts/PermissionContext'; import { QuickSettingsPanel } from '../../quick-settings-panel'; import type { ChatInterfaceProps, Provider } from '../types/types'; @@ -22,7 +23,6 @@ function ChatInterface({ selectedSession, ws, sendMessage, - latestMessage, onFileOpen, onInputFocusChange, onSessionProcessing, @@ -40,14 +40,19 @@ function ChatInterface({ onShowAllTasks, }: ChatInterfaceProps) { const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); + const { subscribe } = useWebSocket(); const { t } = useTranslation('chat'); const sessionStore = useSessionStore(); const streamTimerRef = useRef(null); const accumulatedStreamRef = useRef(''); - // When each session's `check-session-status` was last sent; idle replies - // older than a later local request are discarded as stale. + // When each session's `chat.subscribe` was last sent; idle acks older than + // a later local request are discarded as stale. const statusCheckSentAtRef = useRef(new Map()); + // Highest live `seq` observed per session. Written by the realtime handler + // on every sequenced frame, read whenever a `chat.subscribe` is sent so the + // server replays only the events this client actually missed. + const lastSeqRef = useRef(new Map()); const resetStreamingState = useCallback(() => { if (streamTimerRef.current) { @@ -126,9 +131,18 @@ function ChatInterface({ onSessionIdle, resetStreamingState, statusCheckSentAtRef, + lastSeqRef, sessionStore, }); + // Brand-new conversation: the composer allocated a stable session id via + // the session gateway before the first send. Record it locally and put it + // in the URL — this id never changes again, so there is no later handoff. + const handleSessionEstablished = useCallback((sessionId: string) => { + setCurrentSessionId(sessionId); + onNavigateToSession?.(sessionId); + }, [setCurrentSessionId, onNavigateToSession]); + const { input, setInput, @@ -191,6 +205,7 @@ function ChatInterface({ sendMessage, sendByCtrlEnter, onSessionProcessing, + onSessionEstablished: handleSessionEstablished, onInputFocusChange, onFileOpen, onShowSettings, @@ -201,9 +216,9 @@ function ChatInterface({ }); // On WebSocket reconnect, re-fetch the current session's messages from the - // server so missed streaming events are shown, then re-check the session's - // processing status — the authoritative reply restores or clears the - // activity indicator depending on whether the run is still active. + // server so missed streaming events are shown, then re-subscribe — the + // `chat_subscribed` ack restores or clears the activity indicator, replays + // missed live events, and re-attaches a still-running stream to this socket. const handleWebSocketReconnect = useCallback(async () => { if (!selectedProject || !selectedSession) return; const providerVal = @@ -217,23 +232,28 @@ function ChatInterface({ projectPath: selectedProject.fullPath || selectedProject.path || '', }); statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); - sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider: providerVal }); + sendMessage({ + type: 'chat.subscribe', + sessions: [{ + sessionId: selectedSession.id, + lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0, + }], + }); }, [selectedProject, selectedSession, sendMessage, sessionStore]); useChatRealtimeHandlers({ - latestMessage, + subscribe, provider, selectedSession, currentSessionId, - setCurrentSessionId, setTokenBudget, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, + lastSeqRef, statusCheckSentAtRef, onSessionProcessing, onSessionIdle, - onNavigateToSession, onWebSocketReconnect: handleWebSocketReconnect, sessionStore, }); diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index e04d3bd5..822de23b 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -44,7 +44,6 @@ export type MainContentProps = { setActiveTab: Dispatch>; ws: WebSocket | null; sendMessage: (message: unknown) => void; - latestMessage: unknown; isMobile: boolean; onMenuClick: () => void; isLoading: boolean; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 2db3b583..e5682a1e 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -37,7 +37,6 @@ function MainContent({ setActiveTab, ws, sendMessage, - latestMessage, isMobile, onMenuClick, isLoading, @@ -126,7 +125,6 @@ function MainContent({ selectedSession={selectedSession} ws={ws} sendMessage={sendMessage} - latestMessage={latestMessage} onFileOpen={handleFileOpen} onInputFocusChange={onInputFocusChange} onSessionProcessing={onSessionProcessing} diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx index 456c0761..ef62f3fb 100644 --- a/src/contexts/WebSocketContext.tsx +++ b/src/contexts/WebSocketContext.tsx @@ -2,10 +2,42 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { useAuth } from '../components/auth/context/AuthContext'; import { IS_PLATFORM } from '../constants/config'; +/** + * One frame received from the chat websocket. The server guarantees every + * frame carries a `kind` (provider message kinds plus gateway kinds such as + * `chat_subscribed`, `session_upserted`, `loading_progress`, + * `protocol_error`). The synthetic `websocket_reconnected` kind is injected + * client-side when the socket re-opens after a drop. + */ +export type ServerEvent = { + kind?: string; + type?: string; + sessionId?: string; + seq?: number; + [key: string]: unknown; +}; + +type ServerEventListener = (event: ServerEvent) => void; + type WebSocketContextType = { ws: WebSocket | null; - sendMessage: (message: any) => void; - latestMessage: any | null; + sendMessage: (message: unknown) => void; + /** + * Subscribes to every websocket frame. Returns an unsubscribe function. + * + * This is the primary consumption API: events are dispatched synchronously + * to every listener, so rapid back-to-back frames can never be coalesced or + * dropped the way a single "latest message" state slot could. + */ + subscribe: (listener: ServerEventListener) => () => void; + /** + * Legacy state-based access to the most recent frame. + * + * Kept only for low-frequency consumers (TaskMaster broadcasts). High-rate + * chat streams must use `subscribe` — React may batch state updates, which + * makes `latestMessage` lossy under load. + */ + latestMessage: ServerEvent | null; isConnected: boolean; }; @@ -30,11 +62,28 @@ const useWebSocketProviderState = (): WebSocketContextType => { const wsRef = useRef(null); const unmountedRef = useRef(false); // Track if component is unmounted const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects) - const [latestMessage, setLatestMessage] = useState(null); + /** + * Listener registry for the subscribe API. A ref (not state) because the + * set must be readable synchronously inside `onmessage` and never trigger + * re-renders of the provider tree. + */ + const listenersRef = useRef(new Set()); + const [latestMessage, setLatestMessage] = useState(null); const [isConnected, setIsConnected] = useState(false); const reconnectTimeoutRef = useRef(null); const { token } = useAuth(); + const dispatch = useCallback((event: ServerEvent) => { + for (const listener of listenersRef.current) { + try { + listener(event); + } catch (error) { + console.error('WebSocket listener error:', error); + } + } + setLatestMessage(event); + }, []); + useEffect(() => { // The cleanup below sets unmountedRef = true. Without this reset, every // re-run of the effect (e.g. on token refresh) would short-circuit connect() @@ -60,7 +109,7 @@ const useWebSocketProviderState = (): WebSocketContextType => { const wsUrl = buildWebSocketUrl(token); if (!wsUrl) return console.warn('No authentication token found for WebSocket connection'); - + const websocket = new WebSocket(wsUrl); websocket.onopen = () => { @@ -68,15 +117,15 @@ const useWebSocketProviderState = (): WebSocketContextType => { wsRef.current = websocket; if (hasConnectedRef.current) { // This is a reconnect — signal so components can catch up on missed messages - setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() }); + dispatch({ kind: 'websocket_reconnected', timestamp: Date.now() }); } hasConnectedRef.current = true; }; websocket.onmessage = (event) => { try { - const data = JSON.parse(event.data); - setLatestMessage(data); + const data = JSON.parse(event.data) as ServerEvent; + dispatch(data); } catch (error) { console.error('Error parsing WebSocket message:', error); } @@ -85,7 +134,7 @@ const useWebSocketProviderState = (): WebSocketContextType => { websocket.onclose = () => { setIsConnected(false); wsRef.current = null; - + // Attempt to reconnect after 3 seconds reconnectTimeoutRef.current = setTimeout(() => { if (unmountedRef.current) return; // Prevent reconnection if unmounted @@ -100,9 +149,9 @@ const useWebSocketProviderState = (): WebSocketContextType => { } catch (error) { console.error('Error creating WebSocket connection:', error); } - }, [token]); // everytime token changes, we reconnect + }, [token, dispatch]); // everytime token changes, we reconnect - const sendMessage = useCallback((message: any) => { + const sendMessage = useCallback((message: unknown) => { const socket = wsRef.current; if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message)); @@ -111,20 +160,28 @@ const useWebSocketProviderState = (): WebSocketContextType => { } }, []); + const subscribe = useCallback((listener: ServerEventListener) => { + listenersRef.current.add(listener); + return () => { + listenersRef.current.delete(listener); + }; + }, []); + const value: WebSocketContextType = useMemo(() => ({ ws: wsRef.current, sendMessage, + subscribe, latestMessage, isConnected - }), [sendMessage, latestMessage, isConnected]); + }), [sendMessage, subscribe, latestMessage, isConnected]); return value; }; export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => { const webSocketData = useWebSocketProviderState(); - + return ( {children} diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 1a454b30..44d3af45 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -2,14 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { NavigateFunction } from 'react-router-dom'; import { api } from '../utils/api'; +import type { ServerEvent } from '../contexts/WebSocketContext'; import type { - AppSocketMessage, AppTab, LLMProvider, LoadingProgress, Project, ProjectSession, - ProjectsUpdatedMessage, } from '../types/app'; import type { SessionActivityMap } from './useSessionProtection'; @@ -17,11 +16,30 @@ import type { SessionActivityMap } from './useSessionProtection'; type UseProjectsStateArgs = { sessionId?: string; navigate: NavigateFunction; - latestMessage: AppSocketMessage | null; + /** Subscription to the unified websocket event stream. */ + subscribe: (listener: (event: ServerEvent) => void) => () => void; isMobile: boolean; activeSessions: SessionActivityMap; }; +/** + * Shape of the per-session sidebar delta broadcast by the backend file + * watcher (`kind: session_upserted`). It carries everything needed to upsert + * one session row in place — no full project-list snapshot is ever pushed. + */ +type SessionUpsertedEvent = ServerEvent & { + sessionId: string; + provider: LLMProvider; + session: ProjectSession; + project: { + projectId: string; + path: string; + fullPath: string; + displayName: string; + isStarred: boolean; + } | null; +}; + type FetchProjectsOptions = { showLoadingState?: boolean; }; @@ -187,40 +205,57 @@ const mergeProjectSessionPage = ( return mergedProject; }; -const isUpdateAdditive = ( - currentProjects: Project[], - updatedProjects: Project[], - selectedProject: Project | null, - selectedSession: ProjectSession | null, -): boolean => { - if (!selectedProject || !selectedSession) { - return true; +/** + * Resolves which provider bucket on a `Project` holds sessions for a provider. + * The legacy payload keeps Claude sessions in `sessions` and the other + * providers in their own arrays. + */ +const providerBucketKey = ( + provider: LLMProvider, +): 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' => { + if (provider === 'cursor') return 'cursorSessions'; + if (provider === 'codex') return 'codexSessions'; + if (provider === 'gemini') return 'geminiSessions'; + if (provider === 'opencode') return 'opencodeSessions'; + return 'sessions'; +}; + +/** + * Upserts one session into the matching provider bucket of a project. + * + * Existing rows are updated in place (summary/lastActivity changes from the + * watcher); new rows are prepended since the watcher only fires for sessions + * with fresh activity. `sessionMeta.total` grows only on insert. + */ +const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => { + const bucketKey = providerBucketKey(event.provider); + const bucket = project[bucketKey] ?? []; + const existingIndex = bucket.findIndex((session) => session.id === event.sessionId); + + let nextBucket: ProjectSession[]; + if (existingIndex >= 0) { + const existing = bucket[existingIndex]; + const updated = { ...existing, ...event.session }; + if (serialize(existing) === serialize(updated)) { + return project; + } + nextBucket = [...bucket]; + nextBucket[existingIndex] = updated; + } else { + nextBucket = [event.session, ...bucket]; } - const currentSelectedProject = currentProjects.find((project) => project.projectId === selectedProject.projectId); - const updatedSelectedProject = updatedProjects.find((project) => project.projectId === selectedProject.projectId); - - if (!currentSelectedProject || !updatedSelectedProject) { - return false; + const next: Project = { ...project, [bucketKey]: nextBucket }; + if (existingIndex < 0) { + const total = Number(project.sessionMeta?.total ?? 0) + 1; + next.sessionMeta = { + ...project.sessionMeta, + total, + hasMore: countLoadedProjectSessions(next) < total, + }; } - const currentSelectedSession = getProjectSessions(currentSelectedProject).find( - (session) => session.id === selectedSession.id, - ); - const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find( - (session) => session.id === selectedSession.id, - ); - - if (!currentSelectedSession || !updatedSelectedSession) { - return false; - } - - return ( - currentSelectedSession.id === updatedSelectedSession.id && - currentSelectedSession.title === updatedSelectedSession.title && - currentSelectedSession.created_at === updatedSelectedSession.created_at && - currentSelectedSession.updated_at === updatedSelectedSession.updated_at - ); + return next; }; const VALID_TABS: Set = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']); @@ -244,7 +279,7 @@ const readPersistedTab = (): AppTab => { export function useProjectsState({ sessionId, navigate, - latestMessage, + subscribe, isMobile, activeSessions, }: UseProjectsStateArgs) { @@ -291,7 +326,18 @@ export function useProjectsState({ const [newSessionTrigger, setNewSessionTrigger] = useState(0); const loadingProgressTimeoutRef = useRef | null>(null); - const lastHandledMessageRef = useRef(null); + /** + * Ref mirrors for state the websocket subscription handler needs. + * + * The subscription is registered once (per `subscribe` identity) and events + * are dispatched synchronously outside React's render cycle, so the handler + * must read the latest values through refs instead of stale closures — + * re-subscribing on every state change would risk missing events. + */ + const selectedSessionRef = useRef(selectedSession); + selectedSessionRef.current = selectedSession; + const activeSessionsRef = useRef(activeSessions); + activeSessionsRef.current = activeSessions; const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => { try { @@ -393,98 +439,109 @@ export function useProjectsState({ } }, [isLoadingProjects, projects, selectedProject, sessionId]); + // Realtime sidebar updates. The backend pushes per-session deltas + // (`session_upserted`) instead of full project snapshots, so each event is + // a keyed upsert that can never clobber unrelated client state — no + // "suppress updates while a run is active" protection is needed anymore. useEffect(() => { - if (!latestMessage) { - return; - } - - // `latestMessage` is event-like data. This effect also depends on local state - // (`projects`, `selectedProject`, `selectedSession`) to compute derived updates. - // Without this guard, handling one websocket message can update that local - // state, retrigger the effect, and re-handle the same websocket message. - if (lastHandledMessageRef.current === latestMessage) { - return; - } - lastHandledMessageRef.current = latestMessage; - - if (latestMessage.type === 'loading_progress') { - if (loadingProgressTimeoutRef.current) { - clearTimeout(loadingProgressTimeoutRef.current); - loadingProgressTimeoutRef.current = null; - } - - setLoadingProgress(latestMessage as LoadingProgress); - - if (latestMessage.phase === 'complete') { - loadingProgressTimeoutRef.current = setTimeout(() => { - setLoadingProgress(null); + const handleEvent = (event: ServerEvent) => { + if (event.kind === 'loading_progress') { + if (loadingProgressTimeoutRef.current) { + clearTimeout(loadingProgressTimeoutRef.current); loadingProgressTimeoutRef.current = null; - }, 500); - } - - return; - } - - if (latestMessage.type !== 'projects_updated') { - return; - } - - const projectsMessage = latestMessage as ProjectsUpdatedMessage; - - if (projectsMessage.updatedSessionId && selectedSession && selectedProject) { - if (projectsMessage.updatedSessionId === selectedSession.id) { - const isSessionActive = activeSessions.has(selectedSession.id); - - if (!isSessionActive) { - setExternalMessageUpdate((prev) => prev + 1); } + + setLoadingProgress(event as unknown as LoadingProgress); + + if (event.phase === 'complete') { + loadingProgressTimeoutRef.current = setTimeout(() => { + setLoadingProgress(null); + loadingProgressTimeoutRef.current = null; + }, 500); + } + + return; } - } - const hasActiveSession = Boolean(selectedSession && activeSessions.has(selectedSession.id)); + if (event.kind !== 'session_upserted') { + return; + } - const updatedProjectsWithTaskMaster = mergeTaskMasterCache(projectsMessage.projects, projects); - const updatedProjects = mergeExpandedSessionPages(projects, updatedProjectsWithTaskMaster); + const upsert = event as SessionUpsertedEvent; + if (!upsert.sessionId || !upsert.session) { + return; + } - if ( - hasActiveSession && - !isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession) - ) { - return; - } + // The transcript of the currently viewed session changed on disk while + // no run is active here (e.g. edited from another client or the CLI): + // signal the chat view to reload its messages. + const currentSelectedSession = selectedSessionRef.current; + if ( + currentSelectedSession + && upsert.sessionId === currentSelectedSession.id + && !activeSessionsRef.current.has(upsert.sessionId) + ) { + setExternalMessageUpdate((prev) => prev + 1); + } - setProjects((previousProjects) => - projectsHaveChanges(previousProjects, updatedProjects, true) ? updatedProjects : previousProjects, - ); + setProjects((previousProjects) => { + const targetProjectId = upsert.project?.projectId; + const existingProject = previousProjects.find((project) => + targetProjectId ? project.projectId === targetProjectId : getProjectSessions(project).some((session) => session.id === upsert.sessionId), + ); - if (!selectedProject) { - return; - } + if (!existingProject) { + // First session of a project this client has never seen: create the + // project entry from the event payload. + if (!upsert.project) { + return previousProjects; + } - const updatedSelectedProject = updatedProjects.find( - (project) => project.projectId === selectedProject.projectId, - ); + const newProject: Project = { + projectId: upsert.project.projectId, + path: upsert.project.path, + fullPath: upsert.project.fullPath, + displayName: upsert.project.displayName, + isStarred: upsert.project.isStarred, + sessions: [], + cursorSessions: [], + codexSessions: [], + geminiSessions: [], + opencodeSessions: [], + sessionMeta: { hasMore: false, total: 0 }, + } as Project; - if (!updatedSelectedProject) { - return; - } + return [...previousProjects, upsertSessionIntoProject(newProject, upsert)]; + } - if (serialize(updatedSelectedProject) !== serialize(selectedProject)) { - setSelectedProject(updatedSelectedProject); - } + const updatedProject = upsertSessionIntoProject(existingProject, upsert); + if (updatedProject === existingProject) { + return previousProjects; + } - if (!selectedSession) { - return; - } + return previousProjects.map((project) => + project.projectId === existingProject.projectId ? updatedProject : project, + ); + }); - const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find( - (session) => session.id === selectedSession.id, - ); + // Keep the selected project reference in sync with the upsert. + setSelectedProject((previousProject) => { + if (!previousProject) { + return previousProject; + } + const matches = upsert.project + ? previousProject.projectId === upsert.project.projectId + : getProjectSessions(previousProject).some((session) => session.id === upsert.sessionId); + if (!matches) { + return previousProject; + } + const updated = upsertSessionIntoProject(previousProject, upsert); + return updated === previousProject ? previousProject : updated; + }); + }; - if (!updatedSelectedSession) { - setSelectedSession(null); - } - }, [latestMessage, selectedProject, selectedSession, activeSessions, projects]); + return subscribe(handleEvent); + }, [subscribe]); useEffect(() => { return () => { @@ -578,10 +635,12 @@ export function useProjectsState({ } } - // Session id is in the URL but not yet present on any project payload (common - // right after `session_created` + navigate, before the next projects refresh). - // Without a `selectedSession`, chat state clears `currentSessionId` and the - // UI stops reading the session store even though messages stream under this id. + // Session id is in the URL but not yet present on any project payload + // (normal for a brand-new conversation: the composer allocates the id and + // navigates before the sidebar learns about the session via + // `session_upserted`). Without a `selectedSession`, chat state clears + // `currentSessionId` and the UI stops reading the session store even + // though messages stream under this id — so synthesize a placeholder. if (selectedSession?.id === sessionId) { return; } @@ -637,11 +696,6 @@ export function useProjectsState({ setActiveTab('chat'); } - const provider = localStorage.getItem('selected-provider') || 'claude'; - if (provider === 'cursor') { - sessionStorage.setItem('cursorSessionId', session.id); - } - if (isMobile) { // Sessions are tagged with the owning project's DB `projectId` when // picked from the sidebar (see useSidebarController); compare against diff --git a/src/hooks/useSessionProtection.ts b/src/hooks/useSessionProtection.ts index 97b05a27..e2640b25 100644 --- a/src/hooks/useSessionProtection.ts +++ b/src/hooks/useSessionProtection.ts @@ -1,12 +1,5 @@ import { useCallback, useState } from 'react'; -/** - * Map key for a request that is in flight before the provider has announced - * its real session id (a brand-new conversation). `session_created` migrates - * the entry to the concrete session id. - */ -export const PENDING_SESSION_ID = '__pending_session__'; - export interface SessionActivity { /** Provider-supplied status line; null renders the default activity label. */ statusText: string | null; @@ -34,9 +27,9 @@ export type MarkSessionIdle = ( * Single source of truth for which sessions are actively processing a * request. Everything the chat UI shows (activity indicator, abort * availability, status text) is derived from this map; terminal events - * (`complete`, `error`, abort, an authoritative idle status reply) delete the - * entry atomically. The map also drives session protection: project refreshes - * are suppressed for sessions that have an entry here. + * (`complete`, abort, an authoritative idle subscribe ack) delete the entry + * atomically. Session ids are always concrete (allocated before the first + * send), so entries are keyed by real session ids only. */ export function useSessionProtection() { const [processingSessions, setProcessingSessions] = useState>( @@ -82,9 +75,9 @@ export function useSessionProtection() { return prev; } - // Guard against stale `check-session-status` replies: if a new request - // started after the check was sent, the idle reply describes the older - // request and must not clear the newer one. + // Guard against stale `chat_subscribed` idle acks: if a new request + // started after the subscribe was sent, the idle ack describes the + // older request and must not clear the newer one. if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) { return prev; } diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 1a720d3d..c88e26d5 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -36,6 +36,12 @@ export interface NormalizedMessage { timestamp: string; provider: LLMProvider; kind: MessageKind; + /** + * Per-run monotonic sequence number assigned by the backend to live + * websocket events. Used to compute `lastSeq` for `chat.subscribe` replay; + * REST history messages do not carry it. + */ + seq?: number; // kind-specific fields (flat for simplicity) role?: 'user' | 'assistant'; @@ -186,60 +192,6 @@ function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[ return dedupeAdjacentAssistantEchoes([...server, ...extra]); } -function compareMessagesByTimestamp(left: NormalizedMessage, right: NormalizedMessage): number { - const leftTime = Date.parse(left.timestamp); - const rightTime = Date.parse(right.timestamp); - - if (Number.isNaN(leftTime) || Number.isNaN(rightTime) || leftTime === rightTime) { - return 0; - } - - return leftTime - rightTime; -} - -function rewriteMessageSessionId( - msg: NormalizedMessage, - fromSessionId: string, - toSessionId: string, -): NormalizedMessage { - const streamingSourceId = `__streaming_${fromSessionId}`; - const nextId = msg.id === streamingSourceId ? `__streaming_${toSessionId}` : msg.id; - - if (msg.sessionId === toSessionId && nextId === msg.id) { - return msg; - } - - return { - ...msg, - id: nextId, - sessionId: toSessionId, - }; -} - -function mergeMessagesById( - existing: NormalizedMessage[], - incoming: NormalizedMessage[], -): NormalizedMessage[] { - if (existing.length === 0) return incoming; - if (incoming.length === 0) return existing; - - const merged = [...existing, ...incoming]; - const deduped: NormalizedMessage[] = []; - const seen = new Set(); - - for (const msg of merged) { - if (seen.has(msg.id)) { - continue; - } - - seen.add(msg.id); - deduped.push(msg); - } - - deduped.sort(compareMessagesByTimestamp); - return deduped; -} - /** * Recompute slot.merged only when the input arrays have actually changed * (by reference). Returns true if merged was recomputed. @@ -264,64 +216,39 @@ const MAX_REALTIME_MESSAGES = 500; export function useSessionStore() { const storeRef = useRef(new Map()); - const sessionAliasesRef = useRef(new Map()); const activeSessionIdRef = useRef(null); - // Bump to force re-render — only when the active session's data changes + // Bump to force re-render — only when the active session's data changes. + // Session ids are stable for the whole conversation lifetime (the backend + // allocates them before the first send), so slots are keyed directly with + // no alias/redirect indirection. const [, setTick] = useState(0); const notify = useCallback((sessionId: string) => { - const aliases = sessionAliasesRef.current; - let resolvedSessionId = sessionId; - const visited = new Set(); - - while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) { - visited.add(resolvedSessionId); - resolvedSessionId = aliases.get(resolvedSessionId)!; - } - - if (resolvedSessionId === activeSessionIdRef.current) { + if (sessionId === activeSessionIdRef.current) { setTick(n => n + 1); } }, []); - const resolveSessionId = useCallback((sessionId: string | null | undefined): string | null => { - if (!sessionId) { - return null; - } - - const aliases = sessionAliasesRef.current; - let resolvedSessionId = sessionId; - const visited = new Set(); - - while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) { - visited.add(resolvedSessionId); - resolvedSessionId = aliases.get(resolvedSessionId)!; - } - - return resolvedSessionId; + const setActiveSession = useCallback((sessionId: string | null) => { + activeSessionIdRef.current = sessionId; }, []); - const setActiveSession = useCallback((sessionId: string | null) => { - activeSessionIdRef.current = resolveSessionId(sessionId); - }, [resolveSessionId]); - const getSlot = useCallback((sessionId: string): SessionSlot => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; const store = storeRef.current; - if (!store.has(resolvedSessionId)) { - store.set(resolvedSessionId, createEmptySlot()); + if (!store.has(sessionId)) { + store.set(sessionId, createEmptySlot()); } - return store.get(resolvedSessionId)!; - }, [resolveSessionId]); + return store.get(sessionId)!; + }, []); const has = useCallback((sessionId: string) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - return storeRef.current.has(resolvedSessionId); - }, [resolveSessionId]); + return storeRef.current.has(sessionId); + }, []); /** * Fetch messages from the provider sessions endpoint and populate serverMessages. * * Provider and project metadata are resolved server-side from `sessionId`. + * The endpoint returns the standard `{ success, data }` envelope. */ const fetchFromServer = useCallback(async ( sessionId: string, @@ -333,10 +260,9 @@ export function useSessionStore() { offset?: number; } = {}, ) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = getSlot(resolvedSessionId); + const slot = getSlot(sessionId); slot.status = 'loading'; - notify(resolvedSessionId); + notify(sessionId); try { const params = new URLSearchParams(); @@ -346,14 +272,15 @@ export function useSessionStore() { } const qs = params.toString(); - const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; const response = await authenticatedFetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - const data = await response.json(); + const body = await response.json(); + const data = body?.data ?? body; const messages: NormalizedMessage[] = data.messages || []; slot.serverMessages = messages; @@ -367,15 +294,15 @@ export function useSessionStore() { slot.tokenUsage = data.tokenUsage; } - notify(resolvedSessionId); + notify(sessionId); return slot; } catch (error) { - console.error(`[SessionStore] fetch failed for ${resolvedSessionId}:`, error); + console.error(`[SessionStore] fetch failed for ${sessionId}:`, error); slot.status = 'error'; - notify(resolvedSessionId); + notify(sessionId); return slot; } - }, [getSlot, notify, resolveSessionId]); + }, [getSlot, notify]); /** * Load older (paginated) messages and prepend to serverMessages. @@ -389,8 +316,7 @@ export function useSessionStore() { limit?: number; } = {}, ) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = getSlot(resolvedSessionId); + const slot = getSlot(sessionId); if (!slot.hasMore) return slot; const params = new URLSearchParams(); @@ -399,12 +325,13 @@ export function useSessionStore() { params.append('offset', String(slot.offset)); const qs = params.toString(); - const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; try { const response = await authenticatedFetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const data = await response.json(); + const body = await response.json(); + const data = body?.data ?? body; const olderMessages: NormalizedMessage[] = data.messages || []; // Prepend older messages (they're earlier in the conversation) @@ -412,45 +339,43 @@ export function useSessionStore() { slot.hasMore = Boolean(data.hasMore); slot.offset = slot.offset + olderMessages.length; recomputeMergedIfNeeded(slot); - notify(resolvedSessionId); + notify(sessionId); return slot; } catch (error) { - console.error(`[SessionStore] fetchMore failed for ${resolvedSessionId}:`, error); + console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error); return slot; } - }, [getSlot, notify, resolveSessionId]); + }, [getSlot, notify]); /** * Append a realtime (WebSocket) message to the correct session slot. * This works regardless of which session is actively viewed. */ const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = getSlot(resolvedSessionId); + const slot = getSlot(sessionId); const normalizedMessage = - msg.sessionId === resolvedSessionId + msg.sessionId === sessionId ? msg - : { ...msg, sessionId: resolvedSessionId }; + : { ...msg, sessionId }; let updated = [...slot.realtimeMessages, normalizedMessage]; if (updated.length > MAX_REALTIME_MESSAGES) { updated = updated.slice(-MAX_REALTIME_MESSAGES); } slot.realtimeMessages = updated; recomputeMergedIfNeeded(slot); - notify(resolvedSessionId); - }, [getSlot, notify, resolveSessionId]); + notify(sessionId); + }, [getSlot, notify]); /** * Append multiple realtime messages at once (batch). */ const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => { if (msgs.length === 0) return; - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = getSlot(resolvedSessionId); + const slot = getSlot(sessionId); const normalizedMessages = msgs.map((msg) => - msg.sessionId === resolvedSessionId + msg.sessionId === sessionId ? msg - : { ...msg, sessionId: resolvedSessionId }, + : { ...msg, sessionId }, ); let updated = [...slot.realtimeMessages, ...normalizedMessages]; if (updated.length > MAX_REALTIME_MESSAGES) { @@ -458,8 +383,8 @@ export function useSessionStore() { } slot.realtimeMessages = updated; recomputeMergedIfNeeded(slot); - notify(resolvedSessionId); - }, [getSlot, notify, resolveSessionId]); + notify(sessionId); + }, [getSlot, notify]); /** * Re-fetch serverMessages from the provider sessions endpoint. @@ -472,17 +397,14 @@ export function useSessionStore() { projectPath?: string; } = {}, ) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = getSlot(resolvedSessionId); + const slot = getSlot(sessionId); try { - const params = new URLSearchParams(); - - const qs = params.toString(); - const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`; const response = await authenticatedFetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const data = await response.json(); + const body = await response.json(); + const data = body?.data ?? body; slot.serverMessages = data.messages || []; slot.total = data.total ?? slot.serverMessages.length; @@ -491,43 +413,40 @@ export function useSessionStore() { // drop realtime messages that the server has caught up with to prevent unbounded growth. slot.realtimeMessages = []; recomputeMergedIfNeeded(slot); - notify(resolvedSessionId); + notify(sessionId); } catch (error) { - console.error(`[SessionStore] refresh failed for ${resolvedSessionId}:`, error); + console.error(`[SessionStore] refresh failed for ${sessionId}:`, error); } - }, [getSlot, notify, resolveSessionId]); + }, [getSlot, notify]); /** * Update session status. */ const setStatus = useCallback((sessionId: string, status: SessionStatus) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = getSlot(resolvedSessionId); + const slot = getSlot(sessionId); slot.status = status; - notify(resolvedSessionId); - }, [getSlot, notify, resolveSessionId]); + notify(sessionId); + }, [getSlot, notify]); /** * Check if a session's data is stale (>30s old). */ const isStale = useCallback((sessionId: string) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = storeRef.current.get(resolvedSessionId); + const slot = storeRef.current.get(sessionId); if (!slot) return true; return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS; - }, [resolveSessionId]); + }, []); /** * Update or create a streaming message (accumulated text so far). * Uses a well-known ID so subsequent calls replace the same message. */ const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: LLMProvider) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = getSlot(resolvedSessionId); - const streamId = `__streaming_${resolvedSessionId}`; + const slot = getSlot(sessionId); + const streamId = `__streaming_${sessionId}`; const msg: NormalizedMessage = { id: streamId, - sessionId: resolvedSessionId, + sessionId, timestamp: new Date().toISOString(), provider: msgProvider, kind: 'stream_delta', @@ -541,18 +460,17 @@ export function useSessionStore() { slot.realtimeMessages = [...slot.realtimeMessages, msg]; } recomputeMergedIfNeeded(slot); - notify(resolvedSessionId); - }, [getSlot, notify, resolveSessionId]); + notify(sessionId); + }, [getSlot, notify]); /** * Finalize streaming: convert the streaming message to a regular text message. * The well-known streaming ID is replaced with a unique text message ID. */ const finalizeStreaming = useCallback((sessionId: string) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = storeRef.current.get(resolvedSessionId); + const slot = storeRef.current.get(sessionId); if (!slot) return; - const streamId = `__streaming_${resolvedSessionId}`; + const streamId = `__streaming_${sessionId}`; const idx = slot.realtimeMessages.findIndex(m => m.id === streamId); if (idx >= 0) { const stream = slot.realtimeMessages[idx]; @@ -564,104 +482,35 @@ export function useSessionStore() { role: 'assistant', }; recomputeMergedIfNeeded(slot); - notify(resolvedSessionId); + notify(sessionId); } - }, [notify, resolveSessionId]); + }, [notify]); /** * Clear realtime messages for a session (e.g., after stream completes and server fetch catches up). */ const clearRealtime = useCallback((sessionId: string) => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - const slot = storeRef.current.get(resolvedSessionId); + const slot = storeRef.current.get(sessionId); if (slot) { slot.realtimeMessages = []; recomputeMergedIfNeeded(slot); - notify(resolvedSessionId); + notify(sessionId); } - }, [notify, resolveSessionId]); + }, [notify]); /** * Get merged messages for a session (for rendering). */ const getMessages = useCallback((sessionId: string): NormalizedMessage[] => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - return storeRef.current.get(resolvedSessionId)?.merged ?? []; - }, [resolveSessionId]); + return storeRef.current.get(sessionId)?.merged ?? []; + }, []); /** * Get session slot (for status, pagination info, etc.). */ const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => { - const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; - return storeRef.current.get(resolvedSessionId); - }, [resolveSessionId]); - - const replaceSessionId = useCallback((fromSessionId: string, toSessionId: string) => { - const resolvedFromSessionId = resolveSessionId(fromSessionId) ?? fromSessionId; - const resolvedToSessionId = resolveSessionId(toSessionId) ?? toSessionId; - - if (resolvedFromSessionId === resolvedToSessionId) { - sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId); - return; - } - - const store = storeRef.current; - const sourceSlot = store.get(resolvedFromSessionId); - const targetSlot = store.get(resolvedToSessionId) ?? createEmptySlot(); - - if (sourceSlot) { - const migratedServerMessages = sourceSlot.serverMessages.map((msg) => - rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId), - ); - const migratedRealtimeMessages = sourceSlot.realtimeMessages.map((msg) => - rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId), - ); - - targetSlot.serverMessages = mergeMessagesById(targetSlot.serverMessages, migratedServerMessages); - targetSlot.realtimeMessages = mergeMessagesById(targetSlot.realtimeMessages, migratedRealtimeMessages); - if (targetSlot.realtimeMessages.length > MAX_REALTIME_MESSAGES) { - targetSlot.realtimeMessages = targetSlot.realtimeMessages.slice(-MAX_REALTIME_MESSAGES); - } - targetSlot.status = - sourceSlot.status === 'error' - ? 'error' - : sourceSlot.status === 'streaming' || targetSlot.status === 'streaming' - ? 'streaming' - : sourceSlot.status === 'loading' || targetSlot.status === 'loading' - ? 'loading' - : targetSlot.status; - targetSlot.fetchedAt = Math.max(targetSlot.fetchedAt, sourceSlot.fetchedAt, Date.now()); - targetSlot.total = Math.max( - targetSlot.total, - sourceSlot.total, - targetSlot.serverMessages.length, - targetSlot.realtimeMessages.length, - ); - targetSlot.hasMore = targetSlot.hasMore || sourceSlot.hasMore; - targetSlot.offset = Math.max(targetSlot.offset, sourceSlot.offset); - targetSlot.tokenUsage = targetSlot.tokenUsage ?? sourceSlot.tokenUsage; - recomputeMergedIfNeeded(targetSlot); - - store.set(resolvedToSessionId, targetSlot); - store.delete(resolvedFromSessionId); - } - - sessionAliasesRef.current.set(resolvedFromSessionId, resolvedToSessionId); - sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId); - - for (const [aliasSessionId, targetSessionId] of sessionAliasesRef.current.entries()) { - if (targetSessionId === resolvedFromSessionId) { - sessionAliasesRef.current.set(aliasSessionId, resolvedToSessionId); - } - } - - if (activeSessionIdRef.current === resolvedFromSessionId) { - activeSessionIdRef.current = resolvedToSessionId; - } - - notify(resolvedToSessionId); - }, [notify, resolveSessionId]); + return storeRef.current.get(sessionId); + }, []); return useMemo(() => ({ getSlot, @@ -679,12 +528,11 @@ export function useSessionStore() { clearRealtime, getMessages, getSessionSlot, - replaceSessionId, }), [ getSlot, has, fetchFromServer, fetchMore, appendRealtime, appendRealtimeBatch, refreshFromServer, setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, - clearRealtime, getMessages, getSessionSlot, replaceSessionId, + clearRealtime, getMessages, getSessionSlot, ]); } diff --git a/src/types/app.ts b/src/types/app.ts index aed51fd4..86dc086c 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -70,32 +70,10 @@ export interface Project { } export interface LoadingProgress { - type?: 'loading_progress'; + kind?: 'loading_progress'; phase?: string; current: number; total: number; currentProject?: string; [key: string]: unknown; } - -export interface ProjectsUpdatedMessage { - type: 'projects_updated'; - projects: Project[]; - updatedSessionId?: string; - updatedSessionIds?: string[]; - watchProvider?: LLMProvider; - watchProviders?: LLMProvider[]; - changeType?: 'add' | 'change'; - changeTypes?: Array<'add' | 'change'>; - batched?: boolean; - [key: string]: unknown; -} - -export interface LoadingProgressMessage extends LoadingProgress { - type: 'loading_progress'; -} - -export type AppSocketMessage = - | LoadingProgressMessage - | ProjectsUpdatedMessage - | { type?: string;[key: string]: unknown }; From 881e72d4a00ec9c1a5e1ae4799bffa900f27c1f8 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:31:13 +0300 Subject: [PATCH 04/18] fix: correct notification session id --- server/services/notification-orchestrator.js | 70 +++++++++++--- .../notification-orchestrator.test.js | 80 ++++++++++++++++ src/components/app/AppContent.tsx | 4 + .../chat/hooks/useChatComposerState.ts | 12 ++- src/components/chat/types/types.ts | 7 ++ src/components/chat/view/ChatInterface.tsx | 6 +- src/components/main-content/types/types.ts | 3 +- .../main-content/view/MainContent.tsx | 2 + src/hooks/useProjectsState.ts | 92 +++++++++++++++++++ 9 files changed, 254 insertions(+), 22 deletions(-) create mode 100644 server/services/notification-orchestrator.test.js diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js index 43a7d058..f25fffb3 100644 --- a/server/services/notification-orchestrator.js +++ b/server/services/notification-orchestrator.js @@ -98,6 +98,44 @@ function normalizeSessionName(sessionName) { return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; } +function rowMatchesProvider(row, provider) { + return row && (!provider || row.provider === provider); +} + +function resolveSessionRow(sessionId, provider) { + if (!sessionId) { + return null; + } + + const appSessionRow = sessionsDb.getSessionById(sessionId); + if (rowMatchesProvider(appSessionRow, provider)) { + return appSessionRow; + } + + const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId); + if (rowMatchesProvider(providerSessionRow, provider)) { + return providerSessionRow; + } + + return null; +} + +function normalizeNotificationSession(event) { + if (!event?.sessionId || !event.provider || event.provider === 'system') { + return event; + } + + const row = resolveSessionRow(event.sessionId, event.provider); + if (!row || row.session_id === event.sessionId) { + return event; + } + + return { + ...event, + sessionId: row.session_id + }; +} + function resolveSessionName(event) { const explicitSessionName = normalizeSessionName(event.meta?.sessionName); if (explicitSessionName) { @@ -112,28 +150,29 @@ function resolveSessionName(event) { } function buildPushBody(event) { + const normalizedEvent = normalizeNotificationSession(event); const CODE_MAP = { - 'permission.required': event.meta?.toolName - ? `Action Required: Tool "${event.meta.toolName}" needs approval` + 'permission.required': normalizedEvent.meta?.toolName + ? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval` : 'Action Required: A tool needs your approval', - 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped', - 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error', - 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification', + 'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped', + 'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error', + 'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification', 'push.enabled': 'Push notifications are now enabled!' }; - const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant'; - const sessionName = resolveSessionName(event); - const message = CODE_MAP[event.code] || 'You have a new notification'; + const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant'; + const sessionName = resolveSessionName(normalizedEvent); + const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification'; return { title: sessionName || 'CloudCLI', body: `${providerLabel}: ${message}`, data: { - sessionId: event.sessionId || null, - code: event.code, - provider: event.provider || null, + sessionId: normalizedEvent.sessionId || null, + code: normalizedEvent.code, + provider: normalizedEvent.provider || null, sessionName, - tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}` + tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}` } }; } @@ -175,15 +214,16 @@ function notifyUserIfEnabled({ userId, event }) { return; } + const normalizedEvent = normalizeNotificationSession(event); const preferences = notificationPreferencesDb.getPreferences(userId); - if (!shouldSendPush(preferences, event)) { + if (!shouldSendPush(preferences, normalizedEvent)) { return; } - if (isDuplicate(event)) { + if (isDuplicate(normalizedEvent)) { return; } - sendWebPush(userId, event).catch((err) => { + sendWebPush(userId, normalizedEvent).catch((err) => { console.error('Web push send error:', err); }); } diff --git a/server/services/notification-orchestrator.test.js b/server/services/notification-orchestrator.test.js new file mode 100644 index 00000000..d470765f --- /dev/null +++ b/server/services/notification-orchestrator.test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import webPush from 'web-push'; + +import { + closeConnection, + initializeDatabase, + notificationPreferencesDb, + pushSubscriptionsDb, + sessionsDb, + userDb, +} from '../modules/database/index.js'; + +import { notifyRunStopped } from './notification-orchestrator.js'; + +async function withIsolatedDatabase(runTest) { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'notification-orchestrator-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('push payload uses the app session id when notified with a provider session id', async () => { + const originalSendNotification = webPush.sendNotification; + const sentPayloads = []; + + webPush.sendNotification = async (_subscription, payload) => { + sentPayloads.push(JSON.parse(payload)); + return {}; + }; + + try { + await withIsolatedDatabase(async () => { + const user = userDb.createUser('notify-user', 'hash'); + const userId = Number(user.id); + + notificationPreferencesDb.updatePreferences(userId, { + channels: { webPush: true }, + events: { actionRequired: true, stop: true, error: true }, + }); + pushSubscriptionsDb.saveSubscription(userId, 'https://example.test/push', 'p256dh', 'auth'); + sessionsDb.createAppSession('app-session-1', 'claude', '/workspace/demo'); + sessionsDb.assignProviderSessionId('app-session-1', 'claude-native-1'); + + notifyRunStopped({ + userId, + provider: 'claude', + sessionId: 'claude-native-1', + stopReason: 'completed', + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(sentPayloads.length, 1); + assert.equal(sentPayloads[0]?.data?.sessionId, 'app-session-1'); + assert.match(sentPayloads[0]?.data?.tag, /app-session-1/); + }); + } finally { + webPush.sendNotification = originalSendNotification; + } +}); diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 0e39c956..f4776c88 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -46,6 +46,7 @@ function AppContentInner() { setShowSettings, openSettings, refreshProjectsSilently, + registerOptimisticSession, sidebarSharedProps, handleNewSession, } = useProjectsState({ @@ -172,6 +173,9 @@ function AppContentInner() { onNavigateToSession={(targetSessionId: string, options) => navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) }) } + onSessionEstablished={(targetSessionId, context) => + registerOptimisticSession({ sessionId: targetSessionId, ...context }) + } onShowSettings={() => setShowSettings(true)} externalMessageUpdate={externalMessageUpdate} newSessionTrigger={newSessionTrigger} diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index ed0a4962..c1f86f2d 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -19,6 +19,7 @@ import type { ChatMessage, PendingPermissionRequest, PermissionMode, + SessionEstablishedContext, } from '../types/types'; import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app'; import { escapeRegExp } from '../utils/chatFormatting'; @@ -51,7 +52,7 @@ interface UseChatComposerStateArgs { * stable for the conversation's whole lifetime — the consumer navigates to * /session/:id and records it as the current session. */ - onSessionEstablished?: (sessionId: string) => void; + onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; @@ -606,6 +607,7 @@ export function useChatComposerState({ } const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; + const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); // The conversation always has a stable backend-allocated session id // BEFORE the first websocket send: brand-new chats allocate one here @@ -646,7 +648,11 @@ export function useChatComposerState({ return; } - onSessionEstablished?.(targetSessionId); + onSessionEstablished?.(targetSessionId, { + provider, + project: selectedProject, + summary: sessionSummary, + }); } const userMessage: ChatMessage = { @@ -696,8 +702,6 @@ export function useChatComposerState({ }; const toolsSettings = getToolsSettings(); - const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); - const model = provider === 'cursor' ? cursorModel diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index a8cde669..60ee18fd 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -107,6 +107,12 @@ export type SessionNavigationOptions = { replace?: boolean; }; +export type SessionEstablishedContext = { + provider: LLMProvider; + project: Project; + summary?: string | null; +}; + export interface ChatInterfaceProps { selectedProject: Project | null; selectedSession: ProjectSession | null; @@ -118,6 +124,7 @@ export interface ChatInterfaceProps { onSessionIdle?: MarkSessionIdle; processingSessions?: SessionActivityMap; onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void; + onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onShowSettings?: () => void; autoExpandTools?: boolean; showRawParameters?: boolean; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 7346f1c2..694c4d60 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -29,6 +29,7 @@ function ChatInterface({ onSessionIdle, processingSessions, onNavigateToSession, + onSessionEstablished, onShowSettings, autoExpandTools, showRawParameters, @@ -138,10 +139,11 @@ function ChatInterface({ // Brand-new conversation: the composer allocated a stable session id via // the session gateway before the first send. Record it locally and put it // in the URL — this id never changes again, so there is no later handoff. - const handleSessionEstablished = useCallback((sessionId: string) => { + const handleSessionEstablished = useCallback>((sessionId, context) => { setCurrentSessionId(sessionId); + onSessionEstablished?.(sessionId, context); onNavigateToSession?.(sessionId); - }, [setCurrentSessionId, onNavigateToSession]); + }, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]); const { input, diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index 822de23b..a7398795 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -6,7 +6,7 @@ import type { MarkSessionProcessing, SessionActivityMap, } from '../../../hooks/useSessionProtection'; -import type { SessionNavigationOptions } from '../../chat/types/types'; +import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types'; export type TaskMasterTask = { id: string | number; @@ -52,6 +52,7 @@ export type MainContentProps = { onSessionIdle: MarkSessionIdle; processingSessions: SessionActivityMap; onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void; + onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void; onShowSettings: () => void; externalMessageUpdate: number; newSessionTrigger: number; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index e5682a1e..c085daee 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -45,6 +45,7 @@ function MainContent({ onSessionIdle, processingSessions, onNavigateToSession, + onSessionEstablished, onShowSettings, externalMessageUpdate, newSessionTrigger, @@ -131,6 +132,7 @@ function MainContent({ onSessionIdle={onSessionIdle} processingSessions={processingSessions} onNavigateToSession={onNavigateToSession} + onSessionEstablished={onSessionEstablished} onShowSettings={onShowSettings} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 44d3af45..19ae2fe7 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -44,6 +44,13 @@ type FetchProjectsOptions = { showLoadingState?: boolean; }; +type RegisterOptimisticSessionArgs = { + sessionId: string; + provider: LLMProvider; + project: Project; + summary?: string | null; +}; + const serialize = (value: unknown) => JSON.stringify(value ?? null); const projectsHaveChanges = ( @@ -258,6 +265,21 @@ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent) return next; }; +const projectFromRegistration = (project: Project): Project => ({ + projectId: project.projectId, + path: project.path || project.fullPath, + fullPath: project.fullPath || project.path || '', + displayName: project.displayName, + isStarred: project.isStarred, + sessions: project.sessions ?? [], + cursorSessions: project.cursorSessions ?? [], + codexSessions: project.codexSessions ?? [], + geminiSessions: project.geminiSessions ?? [], + opencodeSessions: project.opencodeSessions ?? [], + sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) }, + taskmaster: project.taskmaster, +}); + const VALID_TABS: Set = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']); const isValidTab = (tab: string): tab is AppTab => { @@ -373,6 +395,75 @@ export function useProjectsState({ await fetchProjects({ showLoadingState: false }); }, [fetchProjects]); + const registerOptimisticSession = useCallback(({ + sessionId: newSessionId, + provider, + project, + summary, + }: RegisterOptimisticSessionArgs) => { + if (!newSessionId || !project?.projectId) { + return; + } + + const now = new Date().toISOString(); + const optimisticSession: ProjectSession = { + id: newSessionId, + summary: summary ?? '', + messageCount: 0, + createdAt: now, + created_at: now, + updated_at: now, + lastActivity: now, + __provider: provider, + __projectId: project.projectId, + }; + const upsert: SessionUpsertedEvent = { + kind: 'session_upserted', + sessionId: newSessionId, + provider, + session: optimisticSession, + project: { + projectId: project.projectId, + path: project.path || project.fullPath, + fullPath: project.fullPath || project.path || '', + displayName: project.displayName, + isStarred: Boolean(project.isStarred), + }, + timestamp: now, + }; + + setProjects((previousProjects) => { + const existingProject = previousProjects.find((candidate) => candidate.projectId === project.projectId); + if (!existingProject) { + return [upsertSessionIntoProject(projectFromRegistration(project), upsert), ...previousProjects]; + } + + const updatedProject = upsertSessionIntoProject(existingProject, upsert); + if (updatedProject === existingProject) { + return previousProjects; + } + + return previousProjects.map((candidate) => + candidate.projectId === existingProject.projectId ? updatedProject : candidate, + ); + }); + + setSelectedProject((previousProject) => { + if (!previousProject || previousProject.projectId !== project.projectId) { + return previousProject; + } + + const updatedProject = upsertSessionIntoProject(previousProject, upsert); + return updatedProject === previousProject ? previousProject : updatedProject; + }); + + setSelectedSession((previousSession) => ( + previousSession?.id === newSessionId + ? { ...previousSession, ...optimisticSession } + : optimisticSession + )); + }, []); + // Hydrates TaskMaster details for the given `projectId`. The project // identifier comes directly from the DB-driven /api/projects response. const hydrateProjectTaskMaster = useCallback(async (projectId: string) => { @@ -950,6 +1041,7 @@ export function useProjectsState({ openSettings, fetchProjects, refreshProjectsSilently, + registerOptimisticSession, sidebarSharedProps, handleProjectSelect, handleSessionSelect, From 591b18e9e343fda23affe100a53911f76aaa8f57 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:54:51 +0300 Subject: [PATCH 05/18] feat(sidebar): improve running session state tracking Add a running-session view to the sidebar, including header controls, running counts, empty states, and row-level processing indicators so active provider work is visible outside the current chat. Hydrate running state after refresh through a status-only /api/providers/sessions/running endpoint backed by chatRunRegistry.listRunningRuns, then sync and poll the frontend processingSessions map from AppContent without attaching to chat streams or replaying messages. Preserve fresh local processing entries during sync so newly sent messages are not cleared before the backend registry catches up, and clear completed sessions once the status endpoint no longer reports them. Thread active session state through sidebar project/session components, show rotating loaders for processing sessions, and keep the running search mode expanded and filterable. Fix optimistic local user-message dedupe so repeated prompts are only collapsed when a matching server echo appears from the same send window, preventing sent messages from disappearing until assistant completion. Add registry test coverage for listing currently running app sessions. Tests: npx eslint on changed files; npx tsc --noEmit -p tsconfig.json; npx tsc --noEmit -p server/tsconfig.json; npx tsx --tsconfig server/tsconfig.json --test server/modules/websocket/tests/chat-run-registry.test.ts. --- server/modules/providers/provider.routes.ts | 8 ++ .../providers/services/sessions.service.ts | 16 ++++ server/modules/websocket/index.ts | 1 + .../services/chat-run-registry.service.ts | 16 ++++ .../websocket/tests/chat-run-registry.test.ts | 32 ++++++++ src/components/app/AppContent.tsx | 78 ++++++++++++++++++- .../sidebar/hooks/useSidebarController.ts | 49 +++++++++++- src/components/sidebar/types/types.ts | 4 +- src/components/sidebar/view/Sidebar.tsx | 6 ++ .../view/subcomponents/SidebarContent.tsx | 46 ++++++++++- .../view/subcomponents/SidebarHeader.tsx | 60 +++++++++++++- .../view/subcomponents/SidebarProjectItem.tsx | 4 + .../view/subcomponents/SidebarProjectList.tsx | 8 +- .../subcomponents/SidebarProjectSessions.tsx | 4 + .../view/subcomponents/SidebarSessionItem.tsx | 36 +++++++-- src/hooks/useProjectsState.ts | 2 + src/hooks/useSessionProtection.ts | 76 ++++++++++++++++++ src/stores/useSessionStore.ts | 41 ++++++++-- src/utils/api.js | 2 + 19 files changed, 465 insertions(+), 24 deletions(-) diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 14f95080..ec76a7db 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -420,6 +420,14 @@ router.post( }), ); +router.get( + '/sessions/running', + asyncHandler(async (_req: Request, res: Response) => { + const sessions = sessionsService.listRunningSessions(); + res.json(createApiSuccessResponse({ sessions })); + }), +); + router.get( '/sessions/archived', asyncHandler(async (_req: Request, res: Response) => { diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 7379b60b..6836e87e 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -3,6 +3,7 @@ import fsp from 'node:fs/promises'; import path from 'node:path'; import { projectsDb, sessionsDb } from '@/modules/database/index.js'; +import { chatRunRegistry } from '@/modules/websocket/index.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js'; import type { FetchHistoryOptions, @@ -84,6 +85,21 @@ export const sessionsService = { return providerRegistry.listProviders().map((provider) => provider.id); }, + /** + * Returns app-facing ids for provider runs that are currently processing. + * + * This is intentionally status-only: callers that only need sidebar activity + * indicators should not attach to chat streams or request replayed messages. + */ + listRunningSessions(): Array<{ + sessionId: string; + provider: LLMProvider; + startedAt: number; + lastSeq: number; + }> { + return chatRunRegistry.listRunningRuns(); + }, + /** * Normalizes one provider-native event into frontend session message events. */ diff --git a/server/modules/websocket/index.ts b/server/modules/websocket/index.ts index da65ee82..56bf1d00 100644 --- a/server/modules/websocket/index.ts +++ b/server/modules/websocket/index.ts @@ -1,2 +1,3 @@ export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js'; export { createWebSocketServer } from './services/websocket-server.service.js'; +export { chatRunRegistry } from './services/chat-run-registry.service.js'; diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts index ae8852bf..c807f209 100644 --- a/server/modules/websocket/services/chat-run-registry.service.ts +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -202,6 +202,22 @@ export const chatRunRegistry = { return runs.get(appSessionId)?.status === 'running'; }, + listRunningRuns(): Array<{ + sessionId: string; + provider: LLMProvider; + startedAt: number; + lastSeq: number; + }> { + return Array.from(runs.values()) + .filter((run) => run.status === 'running') + .map((run) => ({ + sessionId: run.appSessionId, + provider: run.provider, + startedAt: run.startedAt, + lastSeq: run.lastSeq, + })); + }, + /** * Re-attaches a run's outbound stream to a (new) websocket connection. * diff --git a/server/modules/websocket/tests/chat-run-registry.test.ts b/server/modules/websocket/tests/chat-run-registry.test.ts index e9a76df0..bc33b897 100644 --- a/server/modules/websocket/tests/chat-run-registry.test.ts +++ b/server/modules/websocket/tests/chat-run-registry.test.ts @@ -124,6 +124,38 @@ test('complete marks the run finished and duplicate completes are dropped', asyn }); }); +test('listRunningRuns returns only currently running app sessions', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo'); + sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo'); + const connection = new FakeConnection(); + + const completedRun = chatRunRegistry.startRun({ + appSessionId: 'app-run-7', + provider: 'claude', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(completedRun); + + const runningRun = chatRunRegistry.startRun({ + appSessionId: 'app-run-8', + provider: 'codex', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(runningRun); + + chatRunRegistry.completeRun('app-run-7', { exitCode: 0 }); + + const runningSessions = chatRunRegistry.listRunningRuns(); + assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']); + assert.equal(runningSessions[0]?.provider, 'codex'); + }); +}); + test('replayEvents returns only events after the requested seq', async () => { await withIsolatedDatabase(() => { sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo'); diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index f4776c88..27258b35 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -10,6 +10,33 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; +import { api } from '../../utils/api'; + +type RunningSessionApiItem = { + sessionId?: unknown; + startedAt?: unknown; + statusText?: unknown; + canInterrupt?: unknown; +}; + +type RunningSessionsApiPayload = { + data?: { + sessions?: RunningSessionApiItem[]; + }; +}; + +const parseStartedAt = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; export default function AppContent() { return ( @@ -30,6 +57,7 @@ function AppContentInner() { processingSessions, markSessionProcessing, markSessionIdle, + syncProcessingSessions, } = useSessionProtection(); const { @@ -57,6 +85,54 @@ function AppContentInner() { activeSessions: processingSessions, }); + const refreshRunningSessions = useCallback(async () => { + console.log("ASdsad") + try { + const response = await api.runningSessions(); + if (!response.ok) { + return; + } + + const payload = (await response.json()) as RunningSessionsApiPayload; + const sessions = Array.isArray(payload.data?.sessions) ? payload.data.sessions : []; + + syncProcessingSessions( + sessions + .map((session) => { + if (typeof session.sessionId !== 'string' || !session.sessionId) { + return null; + } + + return { + sessionId: session.sessionId, + startedAt: parseStartedAt(session.startedAt), + statusText: typeof session.statusText === 'string' ? session.statusText : undefined, + canInterrupt: typeof session.canInterrupt === 'boolean' ? session.canInterrupt : undefined, + }; + }) + .filter((session): session is NonNullable => Boolean(session)), + ); + } catch (error) { + console.error('[AppContent] Failed to sync running sessions:', error); + } + }, [syncProcessingSessions]); + + useEffect(() => { + void refreshRunningSessions(); + }, [refreshRunningSessions]); + + useEffect(() => { + if (processingSessions.size === 0) { + return; + } + + const interval = window.setInterval(() => { + void refreshRunningSessions(); + }, 5000); + + return () => window.clearInterval(interval); + }, [processingSessions.size, refreshRunningSessions]); + usePaletteOpsRegister({ openSettings, refreshProjects: refreshProjectsSilently, diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index ba559442..a7d539e0 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'; import { api } from '../../../utils/api'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; +import type { SessionActivityMap } from '../../../hooks/useSessionProtection'; import type { ArchivedProjectListItem, ArchivedSessionListItem, @@ -81,6 +82,7 @@ type UseSidebarControllerArgs = { projects: Project[]; selectedProject: Project | null; selectedSession: ProjectSession | null; + activeSessions: SessionActivityMap; isLoading: boolean; isMobile: boolean; t: TFunction; @@ -100,6 +102,7 @@ export function useSidebarController({ projects, selectedProject, selectedSession: _selectedSession, + activeSessions, isLoading, isMobile, t, @@ -146,6 +149,8 @@ export function useSidebarController({ const onRefreshRef = useRef(onRefresh); const isSidebarCollapsed = !isMobile && !sidebarVisible; + const activeSessionIds = useMemo(() => new Set(activeSessions.keys()), [activeSessions]); + const runningSessionsCount = activeSessionIds.size; useEffect(() => { const timer = setInterval(() => { @@ -582,9 +587,48 @@ export function useSidebarController({ [projectSortOrder, projectsWithResolvedStarState], ); + const runningProjects = useMemo(() => { + if (activeSessionIds.size === 0) { + return []; + } + + return sortedProjects.reduce((acc, project) => { + const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); + const cursorSessions = (project.cursorSessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); + const codexSessions = (project.codexSessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); + const geminiSessions = (project.geminiSessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); + const opencodeSessions = (project.opencodeSessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); + const runningCount = + sessions.length + + cursorSessions.length + + codexSessions.length + + geminiSessions.length + + opencodeSessions.length; + + if (runningCount === 0) { + return acc; + } + + acc.push({ + ...project, + sessions, + cursorSessions, + codexSessions, + geminiSessions, + opencodeSessions, + sessionMeta: { + ...project.sessionMeta, + total: runningCount, + hasMore: false, + }, + }); + return acc; + }, []); + }, [activeSessionIds, sortedProjects]); + const filteredProjects = useMemo( - () => filterProjects(sortedProjects, debouncedSearchQuery), - [debouncedSearchQuery, sortedProjects], + () => filterProjects(searchMode === 'running' ? runningProjects : sortedProjects, debouncedSearchQuery), + [debouncedSearchQuery, runningProjects, searchMode, sortedProjects], ); const filteredArchivedSessions = useMemo(() => { @@ -914,6 +958,7 @@ export function useSidebarController({ sessionDeleteConfirmation, showVersionModal, filteredProjects, + runningSessionsCount, archivedProjects: filteredArchivedProjects, archivedSessions: filteredArchivedSessions, archivedSessionsCount: archivedProjects.length + archivedSessions.length, diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index f8c31be2..3e049b3d 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -1,7 +1,8 @@ import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app'; +import type { SessionActivityMap } from '../../../hooks/useSessionProtection'; export type ProjectSortOrder = 'name' | 'date'; -export type SidebarSearchMode = 'projects' | 'conversations' | 'archived'; +export type SidebarSearchMode = 'projects' | 'conversations' | 'running' | 'archived'; export type ArchivedProjectListItem = Project & { isArchived: true }; export type SessionWithProvider = ProjectSession & { @@ -40,6 +41,7 @@ export type SidebarProps = { projects: Project[]; selectedProject: Project | null; selectedSession: ProjectSession | null; + activeSessions: SessionActivityMap; onProjectSelect: (project: Project) => void; onSessionSelect: (session: ProjectSession) => void; onNewSession: (project: Project) => void; diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index ebc046c5..15d96990 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -25,6 +25,7 @@ function Sidebar({ projects, selectedProject, selectedSession, + activeSessions, onProjectSelect, onSessionSelect, onNewSession, @@ -70,6 +71,7 @@ function Sidebar({ isSearching, searchProgress, clearConversationResults, + runningSessionsCount, deletingProjects, deleteConfirmation, sessionDeleteConfirmation, @@ -113,6 +115,7 @@ function Sidebar({ projects, selectedProject, selectedSession, + activeSessions, isLoading, isMobile, t, @@ -159,6 +162,8 @@ function Sidebar({ mcpServerStatus, getProjectSessions, loadingMoreProjects, + activeSessions, + forceExpanded: searchMode === 'running', isProjectStarred, onEditingNameChange: setEditingName, onToggleProject: toggleProject, @@ -229,6 +234,7 @@ function Sidebar({ isMobile={isMobile} isLoading={isLoading} projects={projects} + runningSessionsCount={runningSessionsCount} archivedProjects={archivedProjects} archivedSessions={archivedSessions} archivedSessionsCount={archivedSessionsCount} diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 5ce63b8b..ce8554e2 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -1,16 +1,18 @@ import { type ReactNode } from 'react'; -import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react'; +import { Activity, Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react'; import type { TFunction } from 'i18next'; + import { ScrollArea } from '../../../../shared/view/ui'; import type { Project } from '../../../../types/app'; import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController'; import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; +import { getAllSessions } from '../../utils/utils'; + import SidebarFooter from './SidebarFooter'; import SidebarHeader from './SidebarHeader'; import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList'; -import { getAllSessions } from '../../utils/utils'; function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) { const parts: ReactNode[] = []; @@ -114,6 +116,7 @@ type SidebarContentProps = { isMobile: boolean; isLoading: boolean; projects: Project[]; + runningSessionsCount: number; archivedProjects: ArchivedProjectListItem[]; archivedSessions: ArchivedSessionListItem[]; archivedSessionsCount: number; @@ -152,6 +155,7 @@ export default function SidebarContent({ isMobile, isLoading, projects, + runningSessionsCount, archivedProjects, archivedSessions, archivedSessionsCount, @@ -196,6 +200,7 @@ export default function SidebarContent({ isMobile={isMobile} isLoading={isLoading} projectsCount={projects.length} + runningSessionsCount={runningSessionsCount} archivedSessionsCount={archivedSessionsCount} isArchivedSessionsLoading={isArchivedSessionsLoading} searchFilter={searchFilter} @@ -307,6 +312,39 @@ export default function SidebarContent({ ))}
) : null + ) : searchMode === 'running' ? ( + projectListProps.filteredProjects.length === 0 ? ( +
+
+ +
+

+ {t('running.emptyTitle', 'No sessions running')} +

+

+ {runningSessionsCount > 0 + ? t('running.noMatchingSessions', 'No running sessions match this search.') + : t('running.emptyDescription', 'Active work will appear here while a provider is processing.')} +

+
+ ) : ( +
+
+
+ + + + + {t('running.title', 'Running now')} + +
+ + {runningSessionsCount} + +
+ +
+ ) ) : searchMode === 'archived' ? ( isArchivedSessionsLoading ? (
@@ -358,7 +396,7 @@ export default function SidebarContent({ {project.displayName} - + {t('archived.projectArchived', 'Project archived')}
@@ -448,7 +486,7 @@ export default function SidebarContent({ {group.projectDisplayName} {group.isProjectArchived && ( - + {t('archived.projectArchived', 'Project archived')} )} diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx index 5117b8db..8eabab2a 100644 --- a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx @@ -1,9 +1,11 @@ -import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react'; +import { Activity, Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react'; import type { TFunction } from 'i18next'; + import { Button, Input, Tooltip } from '../../../../shared/view/ui'; import { IS_PLATFORM } from '../../../../constants/config'; import { cn } from '../../../../lib/utils'; import type { SidebarSearchMode } from '../../types/types'; + import GitHubStarBadge from './GitHubStarBadge'; const MOD_KEY = @@ -14,6 +16,7 @@ type SidebarHeaderProps = { isMobile: boolean; isLoading: boolean; projectsCount: number; + runningSessionsCount: number; archivedSessionsCount: number; isArchivedSessionsLoading: boolean; searchFilter: string; @@ -33,6 +36,7 @@ export default function SidebarHeader({ isMobile, isLoading, projectsCount, + runningSessionsCount, archivedSessionsCount, isArchivedSessionsLoading, searchFilter, @@ -46,12 +50,15 @@ export default function SidebarHeader({ onCollapseSidebar, t, }: SidebarHeaderProps) { - const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading; + const showSearchTools = (projectsCount > 0 || runningSessionsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading; const searchPlaceholder = searchMode === 'conversations' ? t('search.conversationsPlaceholder') : searchMode === 'archived' ? t('search.archivedPlaceholder', 'Search archived sessions...') - : t('projects.searchPlaceholder'); + : searchMode === 'running' + ? t('search.runningPlaceholder', 'Search running sessions...') + : t('projects.searchPlaceholder'); + const runningBadgeText = runningSessionsCount > 99 ? '99+' : String(runningSessionsCount); const LogoBlock = () => (
@@ -153,6 +160,29 @@ export default function SidebarHeader({ {t('search.modeConversations')} + + + + + + + {isProcessing && ( +
+ +
+ )} +
; +export type SessionActivitySnapshot = { + sessionId: string; + statusText?: string | null; + canInterrupt?: boolean; + startedAt?: number; +}; + export type MarkSessionProcessing = ( sessionId?: string | null, activity?: { statusText?: string | null; canInterrupt?: boolean }, @@ -23,6 +30,35 @@ export type MarkSessionIdle = ( opts?: { ifStartedBefore?: number }, ) => void; +export type SyncProcessingSessions = ( + sessions: readonly SessionActivitySnapshot[], +) => void; + +const LOCAL_ACTIVITY_GRACE_MS = 10_000; + +const sessionActivityMapsMatch = ( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean => { + if (left.size !== right.size) { + return false; + } + + for (const [sessionId, leftActivity] of left) { + const rightActivity = right.get(sessionId); + if ( + !rightActivity + || leftActivity.statusText !== rightActivity.statusText + || leftActivity.canInterrupt !== rightActivity.canInterrupt + || leftActivity.startedAt !== rightActivity.startedAt + ) { + return false; + } + } + + return true; +}; + /** * Single source of truth for which sessions are actively processing a * request. Everything the chat UI shows (activity indicator, abort @@ -88,9 +124,49 @@ export function useSessionProtection() { }); }, []); + const syncProcessingSessions = useCallback((sessions) => { + const now = Date.now(); + + setProcessingSessions((prev) => { + const incoming = new Map(); + for (const session of sessions) { + if (!session.sessionId) { + continue; + } + incoming.set(session.sessionId, session); + } + + const updated = new Map(); + + for (const [sessionId, snapshot] of incoming) { + const existing = prev.get(sessionId); + const snapshotStartedAt = + typeof snapshot.startedAt === 'number' && Number.isFinite(snapshot.startedAt) && snapshot.startedAt > 0 + ? snapshot.startedAt + : undefined; + + updated.set(sessionId, { + statusText: + snapshot.statusText !== undefined ? snapshot.statusText : existing?.statusText ?? null, + canInterrupt: snapshot.canInterrupt ?? existing?.canInterrupt ?? true, + startedAt: snapshotStartedAt ?? existing?.startedAt ?? now, + }); + } + + for (const [sessionId, activity] of prev) { + if (!incoming.has(sessionId) && now - activity.startedAt < LOCAL_ACTIVITY_GRACE_MS) { + updated.set(sessionId, activity); + } + } + + return sessionActivityMapsMatch(prev, updated) ? prev : updated; + }); + }, []); + return { processingSessions, markSessionProcessing, markSessionIdle, + syncProcessingSessions, }; } diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index c88e26d5..46882464 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -128,12 +128,44 @@ function createEmptySlot(): SessionSlot { * assistant echo (same trimmed text), so finalized stream rows do not stack * on top of the persisted copy before realtime is cleared. */ +const LOCAL_USER_DEDUPE_WINDOW_MS = 5 * 60 * 1000; +const LOCAL_USER_DEDUPE_CLOCK_SKEW_MS = 10_000; + function userTextFingerprint(m: NormalizedMessage): string | null { if (m.kind !== 'text' || m.role !== 'user') return null; const t = (m.content || '').trim(); return t.length > 0 ? t : null; } +function readMessageTime(m: NormalizedMessage): number | null { + const time = Date.parse(m.timestamp); + return Number.isFinite(time) ? time : null; +} + +function hasServerEchoForLocalUser( + localMessage: NormalizedMessage, + serverMessages: NormalizedMessage[], +): boolean { + const localText = userTextFingerprint(localMessage); + const localTime = readMessageTime(localMessage); + if (!localText || localTime === null) { + return false; + } + + return serverMessages.some((serverMessage) => { + if (userTextFingerprint(serverMessage) !== localText) { + return false; + } + + const serverTime = readMessageTime(serverMessage); + return ( + serverTime !== null + && serverTime >= localTime - LOCAL_USER_DEDUPE_CLOCK_SKEW_MS + && serverTime - localTime <= LOCAL_USER_DEDUPE_WINDOW_MS + ); + }); +} + /** * After `finalizeStreaming`, the client holds a synthetic assistant `text` row * while the sessions API soon returns the same reply with a different id. @@ -175,16 +207,13 @@ function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[ if (realtime.length === 0) return server; if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime); const serverIds = new Set(server.map(m => m.id)); - const serverUserTexts = new Set( - server.map(userTextFingerprint).filter((t): t is string => t !== null), - ); const extra = realtime.filter((m) => { if (serverIds.has(m.id)) return false; // Optimistic user rows use `local_*` ids; once the same text exists on the - // server-backed copy, drop the realtime echo to avoid duplicate bubbles. + // server-backed copy from the same send window, drop the realtime echo to + // avoid duplicate bubbles without hiding repeated prompts from history. if (m.id.startsWith('local_')) { - const fp = userTextFingerprint(m); - if (fp && serverUserTexts.has(fp)) return false; + if (hasServerEchoForLocalUser(m, server)) return false; } return true; }); diff --git a/src/utils/api.js b/src/utils/api.js index 999ee316..7502af91 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -98,6 +98,8 @@ export const api = { }, getArchivedSessions: () => authenticatedFetch('/api/providers/sessions/archived'), + runningSessions: () => + authenticatedFetch('/api/providers/sessions/running'), restoreSession: (sessionId) => authenticatedFetch(`/api/providers/sessions/${sessionId}/restore`, { method: 'POST', From 00e526b6e90ee0baf09ebf48873bc10824ab80ba Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:22:07 +0300 Subject: [PATCH 06/18] chore: remove a log --- src/components/app/AppContent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 27258b35..ade11053 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -86,7 +86,6 @@ function AppContentInner() { }); const refreshRunningSessions = useCallback(async () => { - console.log("ASdsad") try { const response = await api.runningSessions(); if (!response.ok) { From 89f05247eddec4fe53bd1616c6a5563e3ae2427a Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:04:31 +0300 Subject: [PATCH 07/18] fix(shell): use correct session id --- server/index.js | 12 ++- .../services/shell-websocket.service.ts | 82 +++++++++++-------- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/server/index.js b/server/index.js index d61c7a9b..4301733b 100755 --- a/server/index.js +++ b/server/index.js @@ -112,7 +112,17 @@ const wss = createWebSocketServer(server, { getPendingApprovalsForSession, }, shell: { - getSessionById: (sessionId) => sessionManager.getSession(sessionId), + resolveProviderSessionId: (sessionId, provider) => { + const dbSession = sessionsDb.getSessionById(sessionId); + const legacyGeminiSession = + provider === 'gemini' ? sessionManager.getSession(sessionId) : null; + + if (dbSession) { + return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null; + } + + return legacyGeminiSession?.cliSessionId; + }, stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index a29959be..d41b781f 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -35,7 +35,10 @@ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000; const SHELL_URL_PARSE_BUFFER_LIMIT = 32768; type ShellWebSocketDependencies = { - getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined; + resolveProviderSessionId: ( + sessionId: string, + provider: string, + ) => string | null | undefined; stripAnsiSequences: (content: string) => string; normalizeDetectedUrl: (url: string) => string | null; extractUrlsFromText: (content: string) => string[]; @@ -76,6 +79,36 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null { return payload as ShellIncomingMessage; } +const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/; + +function resolveResumeSessionId( + message: ShellIncomingMessage, + dependencies: ShellWebSocketDependencies +): string { + const hasSession = readBoolean(message.hasSession); + const sessionId = readString(message.sessionId); + const provider = readString(message.provider, 'claude'); + + if (!hasSession || !sessionId) { + return ''; + } + + let resumeSessionId: string | null | undefined; + try { + resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider); + } catch (error) { + console.error('Failed to resolve provider session ID:', error); + resumeSessionId = undefined; + } + + const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId; + if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) { + return ''; + } + + return resolvedSessionId; +} + /** * Resolves provider command line for plain shell and agent-backed shell modes. */ @@ -84,10 +117,9 @@ function buildShellCommand( dependencies: ShellWebSocketDependencies ): string { const hasSession = readBoolean(message.hasSession); - const sessionId = readString(message.sessionId); const initialCommand = readString(message.initialCommand); const provider = readString(message.provider, 'claude'); - const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/; + const resumeSessionId = resolveResumeSessionId(message, dependencies); const isPlainShell = readBoolean(message.isPlainShell) || (!!initialCommand && !hasSession) || @@ -98,58 +130,43 @@ function buildShellCommand( } if (provider === 'cursor') { - if (hasSession && sessionId) { - return `cursor-agent --resume="${sessionId}"`; + if (resumeSessionId) { + return `cursor-agent --resume="${resumeSessionId}"`; } return 'cursor-agent'; } if (provider === 'codex') { - if (hasSession && sessionId) { + if (resumeSessionId) { if (os.platform() === 'win32') { - return `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; + return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; } - return `codex resume "${sessionId}" || codex`; + return `codex resume "${resumeSessionId}" || codex`; } return 'codex'; } if (provider === 'gemini') { const command = initialCommand || 'gemini'; - let resumeId = sessionId; - if (hasSession && sessionId) { - try { - const existingSession = dependencies.getSessionById(sessionId); - if (existingSession && existingSession.cliSessionId) { - resumeId = existingSession.cliSessionId; - if (!safeSessionIdPattern.test(resumeId)) { - resumeId = ''; - } - } - } catch (error) { - console.error('Failed to get Gemini CLI session ID:', error); - } - } - - if (hasSession && resumeId) { - return `${command} --resume "${resumeId}"`; + if (resumeSessionId) { + return `${command} --resume "${resumeSessionId}"`; } return command; } if (provider === 'opencode') { - if (hasSession && sessionId) { - return `opencode --session "${sessionId}"`; + if (resumeSessionId) { + return `opencode --session "${resumeSessionId}"`; } return initialCommand || 'opencode'; } const command = initialCommand || 'claude'; - if (hasSession && sessionId) { + if (resumeSessionId) { if (os.platform() === 'win32') { - return `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; + return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; } - return `claude --resume "${sessionId}" || claude`; + return `claude --resume "${resumeSessionId}" || claude`; } return command; } @@ -261,6 +278,7 @@ export function handleShellConnection( } const shellCommand = buildShellCommand(data, dependencies); + const resumeSessionId = resolveResumeSessionId(data, dependencies); const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; @@ -406,8 +424,8 @@ export function handleShellConnection( : provider === 'opencode' ? 'OpenCode' : 'Claude'; - welcomeMsg = hasSession - ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` + welcomeMsg = hasSession && resumeSessionId + ? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n` : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; } From 123ae310207fe5969c3b313f62b9dee27e5d7489 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:48:46 +0300 Subject: [PATCH 08/18] fix(chat): sort messages appropriately --- src/components/chat/view/ChatInterface.tsx | 1 + .../view/subcomponents/ChatMessagesPane.tsx | 5 +- src/stores/useSessionStore.ts | 199 +++++++++++++++++- 3 files changed, 193 insertions(+), 12 deletions(-) diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 694c4d60..7630c052 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -325,6 +325,7 @@ function ChatInterface({ onWheel={handleScroll} onTouchMove={handleScroll} isLoadingSessionMessages={isLoadingSessionMessages} + isProcessing={isProcessing} chatMessages={chatMessages} selectedSession={selectedSession} currentSessionId={currentSessionId} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index f4ba1161..5573b31f 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -19,6 +19,8 @@ interface ChatMessagesPaneProps { onWheel: () => void; onTouchMove: () => void; isLoadingSessionMessages: boolean; + /** True while the viewed session has an active provider run in flight. */ + isProcessing?: boolean; chatMessages: ChatMessage[]; selectedSession: ProjectSession | null; currentSessionId: string | null; @@ -68,6 +70,7 @@ export default function ChatMessagesPane({ onWheel, onTouchMove, isLoadingSessionMessages, + isProcessing = false, chatMessages, selectedSession, currentSessionId, @@ -147,7 +150,7 @@ export default function ChatMessagesPane({ onTouchMove={onTouchMove} className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4" > - {isLoadingSessionMessages && chatMessages.length === 0 ? ( + {(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 46882464..3e67026d 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -166,6 +166,108 @@ function hasServerEchoForLocalUser( }); } +function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number { + const timeA = readMessageTime(a) ?? 0; + const timeB = readMessageTime(b) ?? 0; + if (timeA !== timeB) { + return timeA - timeB; + } + return 0; +} + +/** + * Count how many user turns precede `message` in a chronologically merged view + * of server + realtime rows. Used to match a realtime row to the correct turn + * on disk when several turns share identical assistant text. + */ +function getUserTurnOrdinalBefore( + message: NormalizedMessage, + serverMessages: NormalizedMessage[], + realtimeMessages: NormalizedMessage[], +): number { + const messageTime = readMessageTime(message); + let userCount = 0; + + for (const candidate of [...serverMessages, ...realtimeMessages].sort(compareMessagesChronologically)) { + if (candidate.id === message.id) { + break; + } + + const candidateTime = readMessageTime(candidate); + if ( + messageTime !== null + && candidateTime !== null + && candidateTime > messageTime + ) { + break; + } + + if (candidate.kind === 'text' && candidate.role === 'user') { + userCount++; + } + } + + return Math.max(0, userCount - 1); +} + +function findServerTurnRangeByOrdinal( + serverMessages: NormalizedMessage[], + turnOrdinal: number, +): { start: number; end: number } | null { + let userCount = -1; + let start = -1; + + for (let index = 0; index < serverMessages.length; index++) { + const message = serverMessages[index]; + if (message.kind === 'text' && message.role === 'user') { + userCount++; + if (userCount === turnOrdinal) { + start = index; + break; + } + } + } + + if (start < 0) { + return null; + } + + let end = serverMessages.length; + for (let index = start + 1; index < serverMessages.length; index++) { + if (serverMessages[index].kind === 'text' && serverMessages[index].role === 'user') { + end = index; + break; + } + } + + return { start, end }; +} + +function isAssistantTextEchoedInSameTurnOnServer( + message: NormalizedMessage, + serverMessages: NormalizedMessage[], + realtimeMessages: NormalizedMessage[], +): boolean { + const assistantText = (message.content || '').trim(); + if (!assistantText) { + return false; + } + + const turnOrdinal = getUserTurnOrdinalBefore(message, serverMessages, realtimeMessages); + const turnRange = findServerTurnRangeByOrdinal(serverMessages, turnOrdinal); + if (!turnRange) { + return false; + } + + return serverMessages + .slice(turnRange.start + 1, turnRange.end) + .some((serverMessage) => + serverMessage.kind === 'text' + && serverMessage.role === 'assistant' + && (serverMessage.content || '').trim() === assistantText, + ); +} + /** * After `finalizeStreaming`, the client holds a synthetic assistant `text` row * while the sessions API soon returns the same reply with a different id. @@ -203,22 +305,92 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM return out; } +/** + * After a server refresh, drop only the realtime rows the persisted transcript + * already owns. Anything not yet on disk (common right after `complete`, while + * JSONL indexing lags) stays in `realtimeMessages` so the chat pane never + * flashes the empty "Continue your conversation" state. + */ +function pruneRealtimeSupersededByServer( + serverMessages: NormalizedMessage[], + realtimeMessages: NormalizedMessage[], +): NormalizedMessage[] { + if (realtimeMessages.length === 0) { + return realtimeMessages; + } + + const serverIds = new Set(serverMessages.map((message) => message.id)); + + return realtimeMessages.filter((message) => { + if (serverIds.has(message.id)) { + return false; + } + + if (message.id.startsWith('local_') && hasServerEchoForLocalUser(message, serverMessages)) { + return false; + } + + if (message.kind === 'stream_delta' || message.id === `__streaming_${message.sessionId}`) { + if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) { + return false; + } + return true; + } + + if (message.kind === 'text' && message.role === 'assistant') { + if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) { + return false; + } + return true; + } + + if (message.kind === 'text' && message.role === 'user') { + return !hasServerEchoForLocalUser(message, serverMessages); + } + + if (message.kind === 'tool_use' && message.toolId) { + if (serverMessages.some((serverMessage) => serverMessage.kind === 'tool_use' && serverMessage.toolId === message.toolId)) { + return false; + } + } + + return true; + }); +} + function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] { - if (realtime.length === 0) return server; - if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime); - const serverIds = new Set(server.map(m => m.id)); - const extra = realtime.filter((m) => { - if (serverIds.has(m.id)) return false; + if (realtime.length === 0) { + return dedupeAdjacentAssistantEchoes(server); + } + if (server.length === 0) { + return dedupeAdjacentAssistantEchoes(realtime); + } + + const serverIds = new Set(server.map((message) => message.id)); + const extra = realtime.filter((message) => { + if (serverIds.has(message.id)) { + return false; + } // Optimistic user rows use `local_*` ids; once the same text exists on the // server-backed copy from the same send window, drop the realtime echo to // avoid duplicate bubbles without hiding repeated prompts from history. - if (m.id.startsWith('local_')) { - if (hasServerEchoForLocalUser(m, server)) return false; + if (message.id.startsWith('local_')) { + if (hasServerEchoForLocalUser(message, server)) { + return false; + } } return true; }); - if (extra.length === 0) return server; - return dedupeAdjacentAssistantEchoes([...server, ...extra]); + + if (extra.length === 0) { + return dedupeAdjacentAssistantEchoes(server); + } + + // Interleave by timestamp so live rows stay with their turn instead of + // piling up at the bottom after every refresh. + return dedupeAdjacentAssistantEchoes( + [...server, ...extra].sort(compareMessagesChronologically), + ); } /** @@ -439,8 +611,13 @@ export function useSessionStore() { slot.total = data.total ?? slot.serverMessages.length; slot.hasMore = Boolean(data.hasMore); slot.fetchedAt = Date.now(); - // drop realtime messages that the server has caught up with to prevent unbounded growth. - slot.realtimeMessages = []; + // Only drop realtime rows the server transcript now owns. A blind clear + // here caused the chat pane to flash "Continue your conversation" after + // `complete` while JSONL / provider_session_id indexing was still behind. + slot.realtimeMessages = pruneRealtimeSupersededByServer( + slot.serverMessages, + slot.realtimeMessages, + ); recomputeMergedIfNeeded(slot); notify(sessionId); } catch (error) { From 3bbb42c23324c3cbb5587f2bcab09b1dc23086a8 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:52:18 +0300 Subject: [PATCH 09/18] fix(sessions): canonicalize sidebar ids and timestamps The sidebar could keep a provider-native id after backend remapping. That left a duplicate non-working session visible until refresh. Fresh sessions could also appear hours old. SQLite CURRENT_TIMESTAMP is UTC without a timezone suffix. Browser parsing then treated those values like local time. Broadcast a canonical session_upserted event when the provider id is mapped. Collapse provider-id aliases onto the stable app session id in the client. Normalize session-row timestamps to ISO UTC when reading from the repository. --- .../sessions.db.integration.test.ts | 12 +++ .../database/repositories/sessions.db.ts | 51 ++++++++-- .../services/chat-run-registry.service.ts | 56 ++++++++++- .../websocket/tests/chat-run-registry.test.ts | 17 ++-- src/hooks/useProjectsState.ts | 93 +++++++++++++++++-- 5 files changed, 205 insertions(+), 24 deletions(-) diff --git a/server/modules/database/repositories/sessions.db.integration.test.ts b/server/modules/database/repositories/sessions.db.integration.test.ts index d14ec5ae..ecc11c99 100644 --- a/server/modules/database/repositories/sessions.db.integration.test.ts +++ b/server/modules/database/repositories/sessions.db.integration.test.ts @@ -70,3 +70,15 @@ test('createSession reactivates archived rows when the session becomes active ag assert.equal(restoredSession?.isArchived, 0); }); }); + +test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project'); + + const row = sessionsDb.getSessionById('session-timezone'); + assert.ok(row?.created_at.endsWith('Z')); + assert.ok(row?.updated_at.endsWith('Z')); + assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/); + assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/); + }); +}); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index a1aa26b8..698c3e16 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -17,10 +17,19 @@ type SessionRow = { const SESSION_ROW_COLUMNS = 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; +const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + function normalizeTimestamp(value?: string): string | null { if (!value) return null; - const parsed = new Date(value); + // SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix. + // Normalize it here so every session reader returns canonical ISO strings + // and the sidebar never interprets fresh rows as local-time "hours old". + const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value) + ? `${value.replace(' ', 'T')}Z` + : value; + + const parsed = new Date(normalizedValue); if (Number.isNaN(parsed.getTime())) { return null; } @@ -28,6 +37,22 @@ function normalizeTimestamp(value?: string): string | null { return parsed.toISOString(); } +function normalizeSessionRow(row: T): T { + if (!row) { + return row; + } + + return { + ...row, + created_at: normalizeTimestamp(row.created_at) ?? row.created_at, + updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at, + }; +} + +function normalizeSessionRows(rows: SessionRow[]): SessionRow[] { + return rows.map((row) => normalizeSessionRow(row) as SessionRow); +} + function normalizeProjectPathForProvider(provider: string, projectPath: string): string { void provider; return normalizeProjectPath(projectPath); @@ -207,7 +232,7 @@ export const sessionsDb = { ) .get(sessionId) as SessionRow | undefined; - return row ?? null; + return normalizeSessionRow(row) ?? null; }, /** @@ -229,18 +254,20 @@ export const sessionsDb = { ) .get(providerSessionId) as SessionRow | undefined; - return row ?? null; + return normalizeSessionRow(row) ?? null; }, getAllSessions(): SessionRow[] { const db = getConnection(); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 0` ) .all() as SessionRow[]; + + return normalizeSessionRows(rows); }, /** @@ -249,7 +276,7 @@ export const sessionsDb = { */ getArchivedSessions(): SessionRow[] { const db = getConnection(); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions @@ -257,12 +284,14 @@ export const sessionsDb = { ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ) .all() as SessionRow[]; + + return normalizeSessionRows(rows); }, getSessionsByProjectPath(projectPath: string): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions @@ -270,6 +299,8 @@ export const sessionsDb = { AND isArchived = 0` ) .all(normalizedProjectPath) as SessionRow[]; + + return normalizeSessionRows(rows); }, /** @@ -279,19 +310,21 @@ export const sessionsDb = { getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ?` ) .all(normalizedProjectPath) as SessionRow[]; + + return normalizeSessionRows(rows); }, getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions @@ -301,6 +334,8 @@ export const sessionsDb = { LIMIT ? OFFSET ?` ) .all(normalizedProjectPath, limit, offset) as SessionRow[]; + + return normalizeSessionRows(rows); }, countSessionsByProjectPath(projectPath: string): number { diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts index c807f209..a5e51b5f 100644 --- a/server/modules/websocket/services/chat-run-registry.service.ts +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -1,5 +1,9 @@ -import { sessionsDb } from '@/modules/database/index.js'; +import path from 'node:path'; + +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; +import { generateDisplayName } from '@/modules/projects/index.js'; import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js'; +import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; import type { LLMProvider, NormalizedMessage, @@ -58,6 +62,48 @@ const MAX_BUFFERED_EVENTS_PER_RUN = 5000; */ const runs = new Map(); +async function broadcastCanonicalSessionUpsert(appSessionId: string): Promise { + const row = sessionsDb.getSessionById(appSessionId); + if (!row || row.isArchived) { + return; + } + + const projectPath = row.project_path; + const project = projectPath ? projectsDb.getProjectPath(projectPath) : null; + const displayName = project?.custom_project_name?.trim() + ? project.custom_project_name + : await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath); + + const payload = JSON.stringify({ + kind: 'session_upserted', + sessionId: row.session_id, + providerSessionId: row.provider_session_id, + provider: row.provider, + session: { + id: row.session_id, + summary: row.custom_name || '', + messageCount: 0, + lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), + }, + project: project + ? { + projectId: project.project_id, + path: project.project_path, + fullPath: project.project_path, + displayName, + isStarred: Boolean(project.isStarred), + } + : null, + timestamp: new Date().toISOString(), + }); + + connectedClients.forEach((client) => { + if (client.readyState === WS_OPEN_STATE) { + client.send(payload); + } + }); +} + function evictRunLater(appSessionId: string): void { const timer = setTimeout(() => { const run = runs.get(appSessionId); @@ -132,6 +178,14 @@ function recordProviderSessionId(run: ChatRun, providerSessionId: string): void try { sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId); + void broadcastCanonicalSessionUpsert(run.appSessionId).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error('[ChatRunRegistry] Failed to broadcast canonical session mapping', { + appSessionId: run.appSessionId, + providerSessionId, + error: message, + }); + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error('[ChatRunRegistry] Failed to persist provider session id mapping', { diff --git a/server/modules/websocket/tests/chat-run-registry.test.ts b/server/modules/websocket/tests/chat-run-registry.test.ts index bc33b897..cc6250c0 100644 --- a/server/modules/websocket/tests/chat-run-registry.test.ts +++ b/server/modules/websocket/tests/chat-run-registry.test.ts @@ -6,7 +6,7 @@ import test from 'node:test'; import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; -import type { NormalizedMessage } from '@/shared/types.js'; +import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js'; /** * Minimal stand-in for a websocket connection: collects every JSON frame the @@ -14,10 +14,10 @@ import type { NormalizedMessage } from '@/shared/types.js'; */ class FakeConnection { readyState = 1; // WS_OPEN_STATE - frames: NormalizedMessage[] = []; + frames: Array> = []; send(data: string): void { - this.frames.push(JSON.parse(data) as NormalizedMessage); + this.frames.push(JSON.parse(data) as Record); } } @@ -33,6 +33,7 @@ async function withIsolatedDatabase(runTest: () => void | Promise): Promis try { await runTest(); } finally { + connectedClients.clear(); chatRunRegistry.clearAll(); closeConnection(); if (previousDatabasePath === undefined) { @@ -72,6 +73,7 @@ test('session_created is swallowed and persisted as the provider-id mapping', as await withIsolatedDatabase(() => { sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo'); const connection = new FakeConnection(); + connectedClients.add(connection as never); const run = chatRunRegistry.startRun({ appSessionId: 'app-run-2', provider: 'cursor', @@ -88,9 +90,12 @@ test('session_created is swallowed and persisted as the provider-id mapping', as newSessionId: 'cursor-native-7', }); - // Never forwarded to the client... - assert.equal(connection.frames.length, 0); - // ...but recorded in the registry and persisted in the database. + // The provider-native event itself is never forwarded... + const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted'); + assert.equal(sessionUpserts.length, 1); + assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2'); + assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7'); + // ...but the canonical mapping is recorded and persisted in the database. assert.equal(run.providerSessionId, 'cursor-native-7'); assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7'); }); diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 6d0dcfc8..a81ab617 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -29,6 +29,7 @@ type UseProjectsStateArgs = { */ type SessionUpsertedEvent = ServerEvent & { sessionId: string; + providerSessionId?: string | null; provider: LLMProvider; session: ProjectSession; project: { @@ -212,6 +213,26 @@ const mergeProjectSessionPage = ( return mergedProject; }; +const getSessionAliasIds = (event: SessionUpsertedEvent): Set => { + const ids = new Set(); + const add = (value: unknown) => { + if (typeof value !== 'string') { + return; + } + + const trimmed = value.trim(); + if (trimmed) { + ids.add(trimmed); + } + }; + + add(event.sessionId); + add(event.providerSessionId); + add(event.session?.id); + + return ids; +}; + /** * Resolves which provider bucket on a `Project` holds sessions for a provider. * The legacy payload keeps Claude sessions in `sessions` and the other @@ -237,23 +258,47 @@ const providerBucketKey = ( const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => { const bucketKey = providerBucketKey(event.provider); const bucket = project[bucketKey] ?? []; - const existingIndex = bucket.findIndex((session) => session.id === event.sessionId); + const aliasIds = getSessionAliasIds(event); + const normalizedSession: ProjectSession = { + ...event.session, + id: event.sessionId, + }; + const existingIndex = bucket.findIndex((session) => aliasIds.has(String(session.id))); let nextBucket: ProjectSession[]; + let inserted = false; if (existingIndex >= 0) { - const existing = bucket[existingIndex]; - const updated = { ...existing, ...event.session }; - if (serialize(existing) === serialize(updated)) { + let changed = false; + nextBucket = []; + + for (const [index, session] of bucket.entries()) { + if (index === existingIndex) { + const updated = { ...session, ...normalizedSession }; + if (serialize(session) !== serialize(updated)) { + changed = true; + } + nextBucket.push(updated); + continue; + } + + if (aliasIds.has(String(session.id))) { + changed = true; + continue; + } + + nextBucket.push(session); + } + + if (!changed) { return project; } - nextBucket = [...bucket]; - nextBucket[existingIndex] = updated; } else { - nextBucket = [event.session, ...bucket]; + nextBucket = [normalizedSession, ...bucket]; + inserted = true; } const next: Project = { ...project, [bucketKey]: nextBucket }; - if (existingIndex < 0) { + if (inserted) { const total = Number(project.sessionMeta?.total ?? 0) + 1; next.sessionMeta = { ...project.sessionMeta, @@ -629,10 +674,40 @@ export function useProjectsState({ const updated = upsertSessionIntoProject(previousProject, upsert); return updated === previousProject ? previousProject : updated; }); + + const aliasedSelectedSessionId = + typeof upsert.providerSessionId === 'string' && upsert.providerSessionId !== upsert.sessionId + ? upsert.providerSessionId + : null; + if (!aliasedSelectedSessionId) { + return; + } + + const normalizedSelectedSession: ProjectSession = { + ...upsert.session, + id: upsert.sessionId, + __provider: upsert.provider, + __projectId: upsert.project?.projectId ?? currentSelectedSession?.__projectId, + }; + + setSelectedSession((previousSession) => { + if (previousSession?.id !== aliasedSelectedSessionId) { + return previousSession; + } + + return { + ...previousSession, + ...normalizedSelectedSession, + }; + }); + + if (sessionId === aliasedSelectedSessionId) { + navigate(`/session/${upsert.sessionId}`); + } }; return subscribe(handleEvent); - }, [subscribe]); + }, [navigate, sessionId, subscribe]); useEffect(() => { return () => { From 416a737d76e654d2fc649206c2b921a7db150775 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:45:22 +0300 Subject: [PATCH 10/18] fix(opencode): pass workspace dir explicitly The remote environment could start OpenCode runs under /opt/claudecodeui. That happened even when the selected project path was correct. The integration relied on child-process cwd alone. OpenCode run resolves its workspace through the explicit --dir contract. Pass --dir with the resolved working directory. Assert in the CLI test that launch args include the workspace dir. --- server/opencode-cli.js | 4 ++++ server/opencode-cli.test.js | 21 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/server/opencode-cli.js b/server/opencode-cli.js index 237371ba..e8446329 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -194,6 +194,10 @@ async function spawnOpenCode(command, options = {}, ws) { void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => { const args = ['run', '--format', 'json']; + // OpenCode's `run` command owns workspace selection through `--dir`. + // Relying on the child-process cwd alone is not enough on Linux, where + // the CLI can still resolve the session under the server install dir. + args.push('--dir', workingDir); if (sessionId) { args.push('--session', sessionId); } diff --git a/server/opencode-cli.test.js b/server/opencode-cli.test.js index 082451c3..acac202c 100644 --- a/server/opencode-cli.test.js +++ b/server/opencode-cli.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -12,6 +12,11 @@ const findEnvKey = (name) => async function createFakeOpenCodeExecutable(binDir) { const scriptPath = path.join(binDir, 'opencode.js'); await writeFile(scriptPath, ` +const capturePath = process.env.OPENCODE_ARGS_CAPTURE; +if (capturePath) { + require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2))); +} + const events = [ { type: 'text', sessionID: 'open-live-1', text: 'assistant response' }, { type: 'step_finish', sessionID: 'open-live-1' }, @@ -35,10 +40,12 @@ for (const event of events) { test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-')); + const argsCapturePath = path.join(tempRoot, 'opencode-args.json'); const pathKey = findEnvKey('PATH'); const pathExtKey = findEnvKey('PATHEXT'); const previousPath = process.env[pathKey]; const previousPathExt = process.env[pathExtKey]; + const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE; const messages = []; const writer = { userId: null, @@ -54,6 +61,7 @@ test('spawnOpenCode emits session_created before normalized live messages for ne try { await createFakeOpenCodeExecutable(tempRoot); process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`; + process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath; if (process.platform === 'win32') { process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD') ? previousPathExt @@ -77,6 +85,11 @@ test('spawnOpenCode emits session_created before normalized live messages for ne assert.equal(streamEnd?.sessionId, 'open-live-1'); assert.equal(complete?.sessionId, 'open-live-1'); assert.equal(messages.some((message) => message.kind === 'error'), false); + + const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8')); + assert.ok(Array.isArray(launchedArgs)); + assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']); + assert.equal(launchedArgs[4], tempRoot); } finally { if (previousPath === undefined) { delete process.env[pathKey]; @@ -90,6 +103,12 @@ test('spawnOpenCode emits session_created before normalized live messages for ne process.env[pathExtKey] = previousPathExt; } + if (previousArgsCapture === undefined) { + delete process.env.OPENCODE_ARGS_CAPTURE; + } else { + process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture; + } + await rm(tempRoot, { recursive: true, force: true }); } }); From 5b9adbbdee8561439a27ad90744388225823427b Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:22:11 +0300 Subject: [PATCH 11/18] fix(opencode): bind watcher sessions to app rows early --- .../database/repositories/sessions.db.ts | 37 ++++++++++++++ .../opencode-session-synchronizer.provider.ts | 17 +++++-- .../providers/tests/opencode-sessions.test.ts | 49 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 698c3e16..407e4f80 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -257,6 +257,43 @@ export const sessionsDb = { return normalizeSessionRow(row) ?? null; }, + /** + * Finds the newest app-created session for a project that is still waiting + * for its provider-native id to be recorded. + * + * Primary intention: OpenCode can expose a new session in its shared + * `opencode.db` before the websocket runtime reports that same provider id + * back to our app. At that moment the sidebar already has an optimistic + * app-owned session row, but the watcher only knows the provider-native id. + * + * Without this lookup, the synchronizer would insert a second row keyed by + * the provider id, then `assignProviderSessionId()` would merge it a moment + * later. That eventually self-heals, but on slow networks the user can still + * briefly see two sidebar sessions for the same conversation. + * + * This helper lets the synchronizer claim the pending app row first, so the + * provider id is attached before any watcher-created row exists. The result + * is simpler than frontend dedupe and keeps the race resolved at the source. + */ + findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null { + const db = getConnection(); + const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath); + const row = db + .prepare( + `SELECT ${SESSION_ROW_COLUMNS} + FROM sessions + WHERE provider = ? + AND project_path = ? + AND provider_session_id IS NULL + AND isArchived = 0 + ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC + LIMIT 1` + ) + .get(provider, normalizedProjectPath) as SessionRow | undefined; + + return normalizeSessionRow(row) ?? null; + }, + getAllSessions(): SessionRow[] { const db = getConnection(); const rows = db diff --git a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts index 8e7ee213..ea63c776 100644 --- a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +++ b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts @@ -112,6 +112,17 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer } const fallbackTitle = 'Untitled OpenCode Session'; + const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId) + ?? sessionsDb.getSessionById(sessionId) + ?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath); + if (pendingAppSession && !pendingAppSession.provider_session_id) { + // Slow networks can let the sqlite watcher index opencode.db before the + // runtime reports its provider id back through the websocket mapping. + // Bind that id to the fresh app row first so the watcher does not create + // a temporary provider-id sidebar entry for the same session. + sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId); + } + // App-created sessions are keyed by an app id, so disk-discovered provider // ids must be resolved through the provider-id mapping first. const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) @@ -123,7 +134,9 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer // OpenCode stores every session in one shared sqlite database, so jsonl_path // must stay null to avoid deleting opencode.db when one app session is removed. - sessionsDb.createSession( + // Return the canonical stored row id so watcher-triggered sidebar updates + // stay on the app session once provider_session_id has already been mapped. + return sessionsDb.createSession( sessionId, this.provider, projectPath, @@ -132,8 +145,6 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer normalizeProviderTimestamp(row.time_updated ?? row.time_created), null, ); - - return sessionId; } private readFirstUserText(db: Database.Database, sessionId: string): string | undefined { diff --git a/server/modules/providers/tests/opencode-sessions.test.ts b/server/modules/providers/tests/opencode-sessions.test.ts index d5b65e4e..2a98ea6f 100644 --- a/server/modules/providers/tests/opencode-sessions.test.ts +++ b/server/modules/providers/tests/opencode-sessions.test.ts @@ -272,6 +272,55 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr } }); +test('OpenCode session synchronizer returns the app session id once provider mapping exists', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-mapped-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await createOpenCodeDatabase(tempRoot, workspacePath); + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-session-1', 'opencode', workspacePath); + sessionsDb.assignProviderSessionId('app-session-1', 'open-session-1'); + + const synchronizer = new OpenCodeSessionSynchronizer(); + return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => { + assert.equal(sessionId, 'app-session-1'); + assert.equal(sessionsDb.getAllSessions().length, 1); + assert.equal(sessionsDb.getSessionById('app-session-1')?.provider_session_id, 'open-session-1'); + }); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('OpenCode session synchronizer adopts the pending app session before watcher sync creates a duplicate', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-race-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await createOpenCodeDatabase(tempRoot, workspacePath); + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-session-race', 'opencode', workspacePath); + + const synchronizer = new OpenCodeSessionSynchronizer(); + return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => { + assert.equal(sessionId, 'app-session-race'); + assert.equal(sessionsDb.getAllSessions().length, 1); + assert.equal(sessionsDb.getSessionById('app-session-race')?.provider_session_id, 'open-session-1'); + }); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => { const provider = new OpenCodeSessionsProvider(); const normalized = provider.normalizeMessage({ From 7bed675ad5fd1ecf7912d1a04afe9db5b1032823 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:04:56 +0300 Subject: [PATCH 12/18] fix: changes provider logos to svg for fast load --- .../llm-logo-provider/ClaudeLogo.tsx | 27 +++++++--- .../llm-logo-provider/CodexLogo.tsx | 27 +++++----- .../llm-logo-provider/CursorLogo.tsx | 32 +++++++----- .../llm-logo-provider/GeminiLogo.tsx | 50 +++++++++++++++++-- 4 files changed, 98 insertions(+), 38 deletions(-) diff --git a/src/components/llm-logo-provider/ClaudeLogo.tsx b/src/components/llm-logo-provider/ClaudeLogo.tsx index d15a0711..5074e2fd 100644 --- a/src/components/llm-logo-provider/ClaudeLogo.tsx +++ b/src/components/llm-logo-provider/ClaudeLogo.tsx @@ -1,14 +1,27 @@ -import React from 'react'; - type ClaudeLogoProps = { className?: string; }; -const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => { - return ( - Claude - ); -}; +const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => ( + + + + +); export default ClaudeLogo; diff --git a/src/components/llm-logo-provider/CodexLogo.tsx b/src/components/llm-logo-provider/CodexLogo.tsx index 0c3a65f0..2a189600 100644 --- a/src/components/llm-logo-provider/CodexLogo.tsx +++ b/src/components/llm-logo-provider/CodexLogo.tsx @@ -1,20 +1,21 @@ -import React from 'react'; -import { useTheme } from '../../contexts/ThemeContext'; - type CodexLogoProps = { className?: string; }; -const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => { - const { isDarkMode } = useTheme(); - - return ( - Codex ( + + - ); -}; + +); export default CodexLogo; diff --git a/src/components/llm-logo-provider/CursorLogo.tsx b/src/components/llm-logo-provider/CursorLogo.tsx index a44064ac..916de4f4 100644 --- a/src/components/llm-logo-provider/CursorLogo.tsx +++ b/src/components/llm-logo-provider/CursorLogo.tsx @@ -1,20 +1,26 @@ -import React from 'react'; -import { useTheme } from '../../contexts/ThemeContext'; - type CursorLogoProps = { className?: string; }; -const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => { - const { isDarkMode } = useTheme(); - - return ( - Cursor ( + + - ); -}; + + + + + +); export default CursorLogo; diff --git a/src/components/llm-logo-provider/GeminiLogo.tsx b/src/components/llm-logo-provider/GeminiLogo.tsx index 9954dd78..d0624d99 100644 --- a/src/components/llm-logo-provider/GeminiLogo.tsx +++ b/src/components/llm-logo-provider/GeminiLogo.tsx @@ -1,7 +1,47 @@ -const GeminiLogo = ({className = 'w-5 h-5'}) => { - return ( - Gemini - ); +type GeminiLogoProps = { + className?: string; }; -export default GeminiLogo; \ No newline at end of file +const GeminiLogo = ({ className = 'w-5 h-5' }: GeminiLogoProps) => ( + + + + + + + + + + + + + + + + + + + + +); + +export default GeminiLogo; From 1b336e9aa9d2cccf0676d852815d9ba613ac04d2 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:09:59 +0300 Subject: [PATCH 13/18] fix(sidebar): align session status controls across layouts --- .../view/subcomponents/SidebarSessionItem.tsx | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 3772546e..faeb76fe 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -82,6 +82,7 @@ export default function SidebarSessionItem({ const isEditing = editingSession === session.id; const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); const editingContainerRef = useRef(null); + const showRecentIndicator = !isProcessing && sessionView.isActive; // The rename panel sits inside a group-hover opacity wrapper, so leaving the row // would visually hide it. While editing, dismiss only when the user clicks outside @@ -119,7 +120,7 @@ export default function SidebarSessionItem({ return (
- {!isProcessing && sessionView.isActive && ( + {showRecentIndicator && (
-
{sessionView.sessionName}
+
{sessionView.sessionName}
{isProcessing ? ( - - - - - + + + + + + + ) : compactSessionAge && ( {compactSessionAge} )} @@ -176,7 +179,7 @@ export default function SidebarSessionItem({
- {!sessionView.isCursorSession && ( + {!isProcessing && !sessionView.isCursorSession && ( - {isProcessing && ( -
- -
- )} -
- {!sessionView.isCursorSession && ( + {!isProcessing && !sessionView.isCursorSession && (
- {!isProcessing && !sessionView.isCursorSession && ( + {!isProcessing && ( - {!isProcessing && !sessionView.isCursorSession && ( + {!isProcessing && (