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] 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"