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"