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/index.js b/server/index.js index f9455b1e..0a812920 100755 --- a/server/index.js +++ b/server/index.js @@ -22,35 +22,24 @@ import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; import { queryClaudeSDK, abortClaudeSDKSession, - isClaudeSDKSessionActive, - getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, - reconnectSessionWriter, } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, - isCursorSessionActive, - getActiveCursorSessions, } from './cursor-cli.js'; import { queryCodex, abortCodexSession, - isCodexSessionActive, - getActiveCodexSessions, } from './openai-codex.js'; import { spawnGemini, abortGeminiSession, - isGeminiSessionActive, - getActiveGeminiSessions, } from './gemini-cli.js'; import { spawnOpenCode, abortOpenCodeSession, - isOpenCodeSessionActive, - getActiveOpenCodeSessions, } from './opencode-cli.js'; import sessionManager from './sessionManager.js'; import { @@ -107,32 +96,35 @@ const wss = createWebSocketServer(server, { authenticateWebSocket, }, chat: { - queryClaudeSDK, - spawnCursor, - queryCodex, - spawnGemini, - spawnOpenCode, - abortClaudeSDKSession, - abortCursorSession, - abortCodexSession, - abortGeminiSession, - abortOpenCodeSession, + spawnFns: { + claude: queryClaudeSDK, + cursor: spawnCursor, + codex: queryCodex, + gemini: spawnGemini, + opencode: spawnOpenCode, + }, + abortFns: { + claude: abortClaudeSDKSession, + cursor: abortCursorSession, + codex: abortCodexSession, + gemini: abortGeminiSession, + opencode: abortOpenCodeSession, + }, resolveToolApproval, - isClaudeSDKSessionActive, - isCursorSessionActive, - isCodexSessionActive, - isGeminiSessionActive, - isOpenCodeSessionActive, - reconnectSessionWriter, getPendingApprovalsForSession, - getActiveClaudeSDKSessions, - getActiveCursorSessions, - getActiveCodexSessions, - getActiveGeminiSessions, - getActiveOpenCodeSessions, }, shell: { - getSessionById: (sessionId) => sessionManager.getSession(sessionId), + resolveProviderSessionId: (sessionId, provider) => { + const dbSession = sessionsDb.getSessionById(sessionId); + const legacyGeminiSession = + provider === 'gemini' ? sessionManager.getSession(sessionId) : null; + + if (dbSession) { + return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null; + } + + return legacyGeminiSession?.cliSessionId; + }, stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, @@ -1148,7 +1140,6 @@ app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { try { const { projectId, sessionId } = req.params; - const { provider = 'claude' } = req.query; const homeDir = os.homedir(); // Allow only safe characters in sessionId @@ -1157,6 +1148,18 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate return res.status(400).json({ error: 'Invalid sessionId' }); } + // Provider artifacts on disk (JSONL file names, OpenCode sqlite rows) + // are keyed by the provider-native session id, while the caller sends + // the app-facing id. Resolve provider and id mapping from the indexed + // session row so the frontend does not choose provider-specific paths. + const sessionRow = sessionsDb.getSessionById(safeSessionId); + if (!sessionRow) { + return res.status(404).json({ error: 'Session not found', sessionId: safeSessionId }); + } + + const provider = sessionRow.provider || 'claude'; + const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId; + // Handle Cursor sessions - they use SQLite and don't have token usage info if (provider === 'cursor') { return res.json({ @@ -1257,7 +1260,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate tokens_cache_write AS cacheWriteTokens FROM session WHERE id = ? - `).get(safeSessionId); + `).get(providerNativeSessionId); if (!row) { return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId }); @@ -1298,7 +1301,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate if (entry.isDirectory()) { const found = await findSessionFile(fullPath); if (found) return found; - } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) { + } else if (entry.name.includes(providerNativeSessionId) && entry.name.endsWith('.jsonl')) { return fullPath; } } @@ -1382,12 +1385,19 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); - const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); + // Prefer the indexed transcript path (already produced by the trusted + // session synchronizer); fall back to the conventional location + // derived from the provider-native session id. + let jsonlPath = sessionRow?.jsonl_path; + if (!jsonlPath) { + jsonlPath = path.join(projectDir, `${providerNativeSessionId}.jsonl`); - // Constrain to projectDir - const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); - if (rel.startsWith('..') || path.isAbsolute(rel)) { - return res.status(400).json({ error: 'Invalid path' }); + // Constrain the constructed path to projectDir (the id is + // caller-influenced in this fallback branch). + const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + return res.status(400).json({ error: 'Invalid path' }); + } } // Read and parse the JSONL file diff --git a/server/modules/database/migrations.ts b/server/modules/database/migrations.ts index 5b0490cb..05db26bb 100644 --- a/server/modules/database/migrations.ts +++ b/server/modules/database/migrations.ts @@ -382,6 +382,25 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { } }; +/** + * Adds the `provider_session_id` mapping column used by the session gateway. + * + * Rows that existed before this migration were always keyed directly by the + * provider-native session id, so backfilling `provider_session_id` with + * `session_id` keeps every legacy row resolvable through the new mapping. + */ +const addProviderSessionIdMapping = (db: Database): void => { + const sessionsTableInfo = getTableInfo(db, 'sessions'); + const columnNames = sessionsTableInfo.map((column) => column.name); + + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT'); + db.exec(` + UPDATE sessions + SET provider_session_id = session_id + WHERE provider_session_id IS NULL + `); +}; + const ensureProjectsForSessionPaths = (db: Database): void => { if (!tableExists(db, 'sessions')) { return; @@ -428,9 +447,11 @@ export const runMigrations = (db: Database) => { migrateLegacyWorkspaceTableIntoProjects(db); rebuildSessionsTableWithProjectSchema(db); migrateLegacySessionNames(db); + addProviderSessionIdMapping(db); ensureProjectsForSessionPaths(db); db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)'); diff --git a/server/modules/database/repositories/sessions.db.integration.test.ts b/server/modules/database/repositories/sessions.db.integration.test.ts index d14ec5ae..ecc11c99 100644 --- a/server/modules/database/repositories/sessions.db.integration.test.ts +++ b/server/modules/database/repositories/sessions.db.integration.test.ts @@ -70,3 +70,15 @@ test('createSession reactivates archived rows when the session becomes active ag assert.equal(restoredSession?.isArchived, 0); }); }); + +test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project'); + + const row = sessionsDb.getSessionById('session-timezone'); + assert.ok(row?.created_at.endsWith('Z')); + assert.ok(row?.updated_at.endsWith('Z')); + assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/); + assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/); + }); +}); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index d79fdeb8..407e4f80 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -5,6 +5,7 @@ import { normalizeProjectPath } from '@/shared/utils.js'; type SessionRow = { session_id: string; provider: string; + provider_session_id: string | null; project_path: string | null; jsonl_path: string | null; custom_name: string | null; @@ -13,15 +14,22 @@ type SessionRow = { updated_at: string; }; -type SessionMetadataLookupRow = Pick< - SessionRow, - 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at' ->; +const SESSION_ROW_COLUMNS = + 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; + +const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; function normalizeTimestamp(value?: string): string | null { if (!value) return null; - const parsed = new Date(value); + // SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix. + // Normalize it here so every session reader returns canonical ISO strings + // and the sidebar never interprets fresh rows as local-time "hours old". + const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value) + ? `${value.replace(' ', 'T')}Z` + : value; + + const parsed = new Date(normalizedValue); if (Number.isNaN(parsed.getTime())) { return null; } @@ -29,14 +37,38 @@ function normalizeTimestamp(value?: string): string | null { return parsed.toISOString(); } +function normalizeSessionRow(row: T): T { + if (!row) { + return row; + } + + return { + ...row, + created_at: normalizeTimestamp(row.created_at) ?? row.created_at, + updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at, + }; +} + +function normalizeSessionRows(rows: SessionRow[]): SessionRow[] { + return rows.map((row) => normalizeSessionRow(row) as SessionRow); +} + function normalizeProjectPathForProvider(provider: string, projectPath: string): string { void provider; return normalizeProjectPath(projectPath); } export const sessionsDb = { + /** + * Upserts one session row discovered on disk by a provider synchronizer. + * + * The given id is the provider-native session id. Rows are keyed by + * `provider_session_id` so a session that was first created by the app + * (with an app-allocated `session_id`) is updated in place once its + * transcript shows up on disk, instead of producing a duplicate row. + */ createSession( - sessionId: string, + providerSessionId: string, provider: string, projectPath: string, customName?: string, @@ -53,19 +85,54 @@ export const sessionsDb = { // since it's a foreign key in the sessions table. projectsDb.createProjectPath(normalizedProjectPath); + const existing = db + .prepare( + `SELECT session_id FROM sessions + WHERE provider_session_id = ? AND provider = ? + LIMIT 1` + ) + .get(providerSessionId, provider) as { session_id: string } | undefined; + + if (existing) { + db.prepare( + `UPDATE sessions SET + provider = ?, + updated_at = COALESCE(?, CURRENT_TIMESTAMP), + project_path = ?, + jsonl_path = ?, + isArchived = 0, + custom_name = COALESCE(?, custom_name) + WHERE session_id = ?` + ).run( + provider, + updatedAtValue, + normalizedProjectPath, + jsonlPath ?? null, + customName ?? null, + existing.session_id + ); + + return existing.session_id; + } + + // Sessions created outside the app (directly via the provider CLI) are + // keyed by the provider-native id for both columns. The ON CONFLICT path + // covers legacy rows that predate the provider_session_id mapping. db.prepare( - `INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) + `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, + provider_session_id = excluded.provider_session_id, updated_at = excluded.updated_at, project_path = excluded.project_path, jsonl_path = excluded.jsonl_path, isArchived = 0, custom_name = COALESCE(excluded.custom_name, sessions.custom_name)` ).run( - sessionId, + providerSessionId, provider, + providerSessionId, customName ?? null, normalizedProjectPath, jsonlPath ?? null, @@ -73,9 +140,77 @@ export const sessionsDb = { updatedAtValue ); + return providerSessionId; + }, + + /** + * Inserts one app-allocated session row before any provider run happens. + * + * The session gateway uses this when the frontend starts a brand-new chat: + * `session_id` is the stable app-facing id, while `provider_session_id` + * stays NULL until the provider runtime announces its own id and + * `assignProviderSessionId` records the mapping. + */ + createAppSession(sessionId: string, provider: string, projectPath: string): string { + const db = getConnection(); + const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath); + + projectsDb.createProjectPath(normalizedProjectPath); + + db.prepare( + `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) + VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` + ).run(sessionId, provider, normalizedProjectPath); + return sessionId; }, + /** + * Records the provider-native session id for one app-allocated session. + * + * If the filesystem watcher indexed the provider transcript before this + * mapping was recorded (a duplicate row keyed by the provider id exists), + * the duplicate is merged into the app row: its transcript path and name + * are adopted and the duplicate row is removed. Runs in a transaction so + * the sidebar can never observe both rows at once. + */ + assignProviderSessionId(sessionId: string, providerSessionId: string): void { + const db = getConnection(); + + const merge = db.transaction(() => { + const duplicate = db + .prepare( + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions + WHERE (session_id = ? OR provider_session_id = ?) + AND session_id <> ? + LIMIT 1` + ) + .get(providerSessionId, providerSessionId, sessionId) as SessionRow | undefined; + + if (duplicate) { + db.prepare('DELETE FROM sessions WHERE session_id = ?').run(duplicate.session_id); + db.prepare( + `UPDATE sessions SET + provider_session_id = ?, + jsonl_path = COALESCE(jsonl_path, ?), + custom_name = COALESCE(custom_name, ?), + updated_at = CURRENT_TIMESTAMP + WHERE session_id = ?` + ).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId); + return; + } + + db.prepare( + `UPDATE sessions SET + provider_session_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE session_id = ?` + ).run(providerSessionId, sessionId); + }); + + merge(); + }, + updateSessionCustomName(sessionId: string, customName: string): void { const db = getConnection(); db.prepare( @@ -85,30 +220,91 @@ export const sessionsDb = { ).run(customName, sessionId); }, - getSessionById(sessionId: string): SessionMetadataLookupRow | null { + getSessionById(sessionId: string): SessionRow | null { const db = getConnection(); const row = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE session_id = ? ORDER BY updated_at DESC LIMIT 1` ) - .get(sessionId) as SessionMetadataLookupRow | undefined; + .get(sessionId) as SessionRow | undefined; - return row ?? null; + return normalizeSessionRow(row) ?? null; + }, + + /** + * Resolves one session row through the provider-native id. + * + * The filesystem watcher only knows provider ids (they come from transcript + * file names), so it uses this lookup to translate disk artifacts back to + * the app-facing session row before broadcasting sidebar updates. + */ + getSessionByProviderSessionId(providerSessionId: string): SessionRow | null { + const db = getConnection(); + const row = db + .prepare( + `SELECT ${SESSION_ROW_COLUMNS} + FROM sessions + WHERE provider_session_id = ? + ORDER BY updated_at DESC + LIMIT 1` + ) + .get(providerSessionId) as SessionRow | undefined; + + return normalizeSessionRow(row) ?? null; + }, + + /** + * Finds the newest app-created session for a project that is still waiting + * for its provider-native id to be recorded. + * + * Primary intention: OpenCode can expose a new session in its shared + * `opencode.db` before the websocket runtime reports that same provider id + * back to our app. At that moment the sidebar already has an optimistic + * app-owned session row, but the watcher only knows the provider-native id. + * + * Without this lookup, the synchronizer would insert a second row keyed by + * the provider id, then `assignProviderSessionId()` would merge it a moment + * later. That eventually self-heals, but on slow networks the user can still + * briefly see two sidebar sessions for the same conversation. + * + * This helper lets the synchronizer claim the pending app row first, so the + * provider id is attached before any watcher-created row exists. The result + * is simpler than frontend dedupe and keeps the race resolved at the source. + */ + findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null { + const db = getConnection(); + const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath); + const row = db + .prepare( + `SELECT ${SESSION_ROW_COLUMNS} + FROM sessions + WHERE provider = ? + AND project_path = ? + AND provider_session_id IS NULL + AND isArchived = 0 + ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC + LIMIT 1` + ) + .get(provider, normalizedProjectPath) as SessionRow | undefined; + + return normalizeSessionRow(row) ?? null; }, getAllSessions(): SessionRow[] { const db = getConnection(); - return db + const rows = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 0` ) .all() as SessionRow[]; + + return normalizeSessionRows(rows); }, /** @@ -117,27 +313,31 @@ export const sessionsDb = { */ getArchivedSessions(): SessionRow[] { const db = getConnection(); - return db + const rows = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 1 ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ) .all() as SessionRow[]; + + return normalizeSessionRows(rows); }, getSessionsByProjectPath(projectPath: string): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ? AND isArchived = 0` ) .all(normalizedProjectPath) as SessionRow[]; + + return normalizeSessionRows(rows); }, /** @@ -147,21 +347,23 @@ export const sessionsDb = { getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ?` ) .all(normalizedProjectPath) as SessionRow[]; + + return normalizeSessionRows(rows); }, getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ? AND isArchived = 0 @@ -169,6 +371,8 @@ export const sessionsDb = { LIMIT ? OFFSET ?` ) .all(normalizedProjectPath, limit, offset) as SessionRow[]; + + return normalizeSessionRows(rows); }, countSessionsByProjectPath(projectPath: string): number { diff --git a/server/modules/database/schema.ts b/server/modules/database/schema.ts index b3639af2..f4cc1b53 100644 --- a/server/modules/database/schema.ts +++ b/server/modules/database/schema.ts @@ -83,6 +83,12 @@ export const SESSIONS_TABLE_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT NOT NULL, provider TEXT NOT NULL DEFAULT 'claude', + -- The session id used by the provider CLI/SDK on disk (JSONL file name, + -- store.db folder, sqlite row id, ...). \`session_id\` is the stable + -- app-facing id that the frontend uses for the whole session lifetime; + -- \`provider_session_id\` is filled in once the provider announces its own + -- id mid-run, or equals \`session_id\` for sessions discovered on disk. + provider_session_id TEXT, custom_name TEXT, project_path TEXT, jsonl_path TEXT, diff --git a/server/modules/database/tests/sessions-provider-mapping.test.ts b/server/modules/database/tests/sessions-provider-mapping.test.ts new file mode 100644 index 00000000..a9d91478 --- /dev/null +++ b/server/modules/database/tests/sessions-provider-mapping.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection } from '@/modules/database/connection.js'; +import { initializeDatabase } from '@/modules/database/init-db.js'; +import { sessionsDb } from '@/modules/database/repositories/sessions.db.js'; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-mapping-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('disk-discovered sessions are keyed by the provider id for both columns', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createSession('provider-abc', 'claude', '/workspace/demo', 'From Disk'); + + const row = sessionsDb.getSessionById('provider-abc'); + assert.equal(row?.session_id, 'provider-abc'); + assert.equal(row?.provider_session_id, 'provider-abc'); + + const byProviderId = sessionsDb.getSessionByProviderSessionId('provider-abc'); + assert.equal(byProviderId?.session_id, 'provider-abc'); + }); +}); + +test('app sessions get the provider id assigned without creating a duplicate row', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-id-1', 'claude', '/workspace/demo'); + sessionsDb.assignProviderSessionId('app-id-1', 'provider-xyz'); + + // A later synchronizer pass that discovers the transcript on disk must + // update the app row in place instead of inserting a provider-keyed row. + const returnedId = sessionsDb.createSession( + 'provider-xyz', + 'claude', + '/workspace/demo', + 'Synced Name', + undefined, + undefined, + '/fake/path/provider-xyz.jsonl', + ); + + assert.equal(returnedId, 'app-id-1'); + assert.equal(sessionsDb.getAllSessions().length, 1); + + const row = sessionsDb.getSessionById('app-id-1'); + assert.equal(row?.provider_session_id, 'provider-xyz'); + assert.equal(row?.jsonl_path, '/fake/path/provider-xyz.jsonl'); + }); +}); + +test('assignProviderSessionId merges a watcher-created duplicate into the app row', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-id-2', 'codex', '/workspace/demo'); + + // Simulate the race: the filesystem watcher indexed the provider + // transcript before the runtime announced its session id to the gateway. + sessionsDb.createSession( + 'provider-race', + 'codex', + '/workspace/demo', + 'Watcher Name', + undefined, + undefined, + '/fake/provider-race.jsonl', + ); + assert.equal(sessionsDb.getAllSessions().length, 2); + + sessionsDb.assignProviderSessionId('app-id-2', 'provider-race'); + + const rows = sessionsDb.getAllSessions(); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.session_id, 'app-id-2'); + assert.equal(rows[0]?.provider_session_id, 'provider-race'); + // Transcript path and name from the duplicate are adopted. + assert.equal(rows[0]?.jsonl_path, '/fake/provider-race.jsonl'); + assert.equal(rows[0]?.custom_name, 'Watcher Name'); + }); +}); + +test('legacy provider-keyed rows stay resolvable through both lookups', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createSession('legacy-1', 'gemini', '/workspace/demo'); + + assert.equal(sessionsDb.getSessionById('legacy-1')?.provider, 'gemini'); + assert.equal(sessionsDb.getSessionByProviderSessionId('legacy-1')?.session_id, 'legacy-1'); + }); +}); diff --git a/server/modules/projects/services/project-management.service.ts b/server/modules/projects/services/project-management.service.ts index 674418cc..913c2fa3 100644 --- a/server/modules/projects/services/project-management.service.ts +++ b/server/modules/projects/services/project-management.service.ts @@ -30,10 +30,6 @@ type ProjectApiView = { isArchived: boolean; isStarred: boolean; sessions: []; - cursorSessions: []; - codexSessions: []; - geminiSessions: []; - opencodeSessions: []; sessionMeta: { hasMore: false; total: 0; @@ -82,10 +78,6 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie isArchived: Boolean(projectRow.isArchived), isStarred: Boolean(projectRow.isStarred), sessions: [], - cursorSessions: [], - codexSessions: [], - geminiSessions: [], - opencodeSessions: [], sessionMeta: { hasMore: false, total: 0, diff --git a/server/modules/projects/services/projects-with-sessions-fetch.service.ts b/server/modules/projects/services/projects-with-sessions-fetch.service.ts index bb252f69..296ec594 100644 --- a/server/modules/projects/services/projects-with-sessions-fetch.service.ts +++ b/server/modules/projects/services/projects-with-sessions-fetch.service.ts @@ -9,13 +9,12 @@ import { AppError } from '@/shared/utils.js'; type SessionSummary = { id: string; + provider: string; summary: string; messageCount: number; lastActivity: string; }; -type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>; - type SessionRepositoryRow = { provider: string; session_id: string; @@ -31,10 +30,6 @@ export type ProjectListItem = { fullPath: string; isStarred: boolean; sessions: SessionSummary[]; - cursorSessions: SessionSummary[]; - codexSessions: SessionSummary[]; - geminiSessions: SessionSummary[]; - opencodeSessions: SessionSummary[]; sessionMeta: { hasMore: boolean; total: number; @@ -64,7 +59,7 @@ type SessionPaginationOptions = { }; type ProjectSessionsPageResult = { - sessionsByProvider: SessionsByProvider; + sessions: SessionSummary[]; total: number; hasMore: boolean; }; @@ -72,10 +67,6 @@ type ProjectSessionsPageResult = { export type ProjectSessionsPageApiView = { projectId: string; sessions: SessionSummary[]; - cursorSessions: SessionSummary[]; - codexSessions: SessionSummary[]; - geminiSessions: SessionSummary[]; - opencodeSessions: SessionSummary[]; sessionMeta: { hasMore: boolean; total: number; @@ -129,39 +120,18 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary { return { id: row.session_id, + provider: row.provider, summary: row.custom_name || '', messageCount: 0, lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), }; } -function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider { - const byProvider: SessionsByProvider = { - claude: [], - cursor: [], - codex: [], - gemini: [], - opencode: [], - }; - - for (const row of rows) { - const provider = row.provider as keyof SessionsByProvider; - const bucket = byProvider[provider]; - if (!bucket) { - continue; - } - - bucket.push(mapSessionRowToSummary(row)); - } - - return byProvider; -} - function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult { const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[]; return { - sessionsByProvider: bucketSessionRowsByProvider(rows), + sessions: rows.map(mapSessionRowToSummary), total: rows.length, hasMore: false, }; @@ -183,16 +153,17 @@ function readProjectSessionsPageByPath( const total = sessionsDb.countSessionsByProjectPath(projectPath); return { - sessionsByProvider: bucketSessionRowsByProvider(rows), + sessions: rows.map(mapSessionRowToSummary), total, hasMore: pagination.offset + rows.length < total, }; } -// Broadcast progress to all connected WebSocket clients +// Broadcast progress to all connected WebSocket clients. +// Uses the unified `kind` envelope like every other websocket frame. function broadcastProgress(progress: ProgressUpdate) { const message = JSON.stringify({ - type: 'loading_progress', + kind: 'loading_progress', ...progress, }); @@ -204,7 +175,7 @@ function broadcastProgress(progress: ProgressUpdate) { } /** - * Reads all projects from DB and returns provider-bucketed session summaries. + * Reads all projects from DB and returns normalized session summaries. */ export async function getProjectsWithSessions( options: GetProjectsWithSessionsOptions = {} @@ -252,11 +223,7 @@ export async function getProjectsWithSessions( displayName, fullPath: projectPath, isStarred: Boolean(row.isStarred), - sessions: sessionsPage.sessionsByProvider.claude, - cursorSessions: sessionsPage.sessionsByProvider.cursor, - codexSessions: sessionsPage.sessionsByProvider.codex, - geminiSessions: sessionsPage.sessionsByProvider.gemini, - opencodeSessions: sessionsPage.sessionsByProvider.opencode, + sessions: sessionsPage.sessions, sessionMeta: { hasMore: sessionsPage.hasMore, total: sessionsPage.total, @@ -309,11 +276,7 @@ export async function getArchivedProjectsWithSessions( fullPath: row.project_path, isStarred: Boolean(row.isStarred), isArchived: true, - sessions: sessionsPage.sessionsByProvider.claude, - cursorSessions: sessionsPage.sessionsByProvider.cursor, - codexSessions: sessionsPage.sessionsByProvider.codex, - geminiSessions: sessionsPage.sessionsByProvider.gemini, - opencodeSessions: sessionsPage.sessionsByProvider.opencode, + sessions: sessionsPage.sessions, sessionMeta: { hasMore: sessionsPage.hasMore, total: sessionsPage.total, @@ -342,11 +305,7 @@ export async function getProjectSessionsPage( const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options); return { projectId: projectRow.project_id, - sessions: sessionsPage.sessionsByProvider.claude, - cursorSessions: sessionsPage.sessionsByProvider.cursor, - codexSessions: sessionsPage.sessionsByProvider.codex, - geminiSessions: sessionsPage.sessionsByProvider.gemini, - opencodeSessions: sessionsPage.sessionsByProvider.opencode, + sessions: sessionsPage.sessions, sessionMeta: { hasMore: sessionsPage.hasMore, total: sessionsPage.total, diff --git a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts index 1bf3bffc..530e4328 100644 --- a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +++ b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts @@ -111,7 +111,10 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { return null; } - const existingSession = sessionsDb.getSessionById(parsed.sessionId); + // App-created sessions are keyed by an app id, so disk-discovered provider + // ids must be resolved through the provider-id mapping first. + const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) + ?? sessionsDb.getSessionById(parsed.sessionId); const existingSessionName = existingSession?.custom_name; if (existingSessionName && existingSessionName !== 'Untitled Claude Session') { return { diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index f803d92c..0c7c27c2 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -5,7 +5,7 @@ import readline from 'node:readline'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; +import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; import { sessionsDb } from '@/modules/database/index.js'; const PROVIDER = 'claude'; @@ -103,10 +103,13 @@ async function parseAgentTools(filePath: string): Promise { async function getSessionMessages( sessionId: string, + providerSessionId: string, limit: number | null, offset: number, ): Promise { try { + // The DB row is keyed by the app-facing session id, while the JSONL rows + // on disk carry the provider-native id — both ids are needed here. const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path; if (!jsonLPath) { @@ -133,7 +136,7 @@ async function getSessionMessages( try { const entry = JSON.parse(line) as AnyRecord; - if (entry.sessionId === sessionId) { + if (entry.sessionId === providerSessionId) { messages.push(entry); } } catch { @@ -553,12 +556,13 @@ export class ClaudeSessionsProvider implements IProviderSessions { options: FetchHistoryOptions = {}, ): Promise { const { limit = null, offset = 0 } = options; + const providerSessionId = options.providerSessionId ?? sessionId; let result: ClaudeHistoryResult; try { // Load full history first so `total` reflects frontend-normalized messages, // not raw JSONL records. - result = await getSessionMessages(sessionId, null, 0); + result = await getSessionMessages(sessionId, providerSessionId, null, 0); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message); @@ -606,7 +610,6 @@ export class ClaudeSessionsProvider implements IProviderSessions { } } - const totalNormalized = normalized.length; let total = 0; for (const msg of normalized) { if (msg.kind !== 'tool_result') { @@ -615,18 +618,10 @@ export class ClaudeSessionsProvider implements IProviderSessions { } const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); - const messages = normalizedLimit === null - ? normalized - : normalized.slice( - Math.max(0, totalNormalized - normalizedOffset - normalizedLimit), - Math.max(0, totalNormalized - normalizedOffset), - ); - const hasMore = normalizedLimit === null - ? false - : Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0; + const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); return { - messages, + messages: page, total, hasMore, offset: normalizedOffset, diff --git a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts index 0e8025ef..818d71c9 100644 --- a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +++ b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts @@ -43,11 +43,12 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { continue; } - const existingSession = sessionsDb.getSessionById(parsed.sessionId); + const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) + ?? sessionsDb.getSessionById(parsed.sessionId); if (existingSession) { // If session name is untitled and we now have a name, update it if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') { - sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName); + sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName); } } @@ -120,7 +121,10 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { return null; } - const existingSession = sessionsDb.getSessionById(parsed.sessionId); + // App-created sessions are keyed by an app id, so disk-discovered provider + // ids must be resolved through the provider-id mapping first. + const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) + ?? sessionsDb.getSessionById(parsed.sessionId); const existingSessionName = existingSession?.custom_name; if (existingSessionName && existingSessionName !== 'Untitled Codex Session') { return { diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts index 5cad1334..d166d20c 100644 --- a/server/modules/providers/list/codex/codex-sessions.provider.ts +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -4,7 +4,7 @@ import readline from 'node:readline'; import { sessionsDb } from '@/modules/database/index.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; +import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; const PROVIDER = 'codex'; @@ -552,7 +552,6 @@ export class CodexSessionsProvider implements IProviderSessions { } } - const totalNormalized = normalized.length; let total = 0; for (const msg of normalized) { if (msg.kind !== 'tool_result') { @@ -561,18 +560,10 @@ export class CodexSessionsProvider implements IProviderSessions { } const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); - const messages = normalizedLimit === null - ? normalized - : normalized.slice( - Math.max(0, totalNormalized - normalizedOffset - normalizedLimit), - Math.max(0, totalNormalized - normalizedOffset), - ); - const hasMore = normalizedLimit === null - ? false - : Math.max(0, totalNormalized - normalizedOffset - normalizedLimit) > 0; + const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); return { - messages, + messages: page, total, hasMore, offset: normalizedOffset, diff --git a/server/modules/providers/list/cursor/cursor-sessions.provider.ts b/server/modules/providers/list/cursor/cursor-sessions.provider.ts index 33f93ea5..307d9638 100644 --- a/server/modules/providers/list/cursor/cursor-sessions.provider.ts +++ b/server/modules/providers/list/cursor/cursor-sessions.provider.ts @@ -9,6 +9,7 @@ import { generateMessageId, readObjectRecord, sanitizeLeafDirectoryName, + sliceTailPage, } from '@/shared/utils.js'; const PROVIDER = 'cursor'; @@ -363,42 +364,32 @@ export class CursorSessionsProvider implements IProviderSessions { /** * Fetches and paginates Cursor session history from its project-scoped store.db. + * + * Pagination follows the shared tail contract (`sliceTailPage`): offset 0 is + * the most recent page, matching every other provider. */ async fetchHistory( sessionId: string, options: FetchHistoryOptions = {}, ): Promise { const { projectPath = '', limit = null, offset = 0 } = options; + // The store.db folder on disk is named after the provider-native id, not + // the app-facing session id this method is addressed with. + const providerSessionId = options.providerSessionId ?? sessionId; try { - const blobs = await this.loadCursorBlobs(sessionId, projectPath); + const blobs = await this.loadCursorBlobs(providerSessionId, projectPath); const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result'); const total = renderableMessages.length; - - if (limit !== null) { - const start = offset; - const page = limit === 0 - ? [] - : renderableMessages.slice(start, start + limit); - const hasMore = limit === 0 - ? start < total - : start + limit < total; - return { - messages: page, - total, - hasMore, - offset, - limit, - }; - } + const { page, hasMore } = sliceTailPage(renderableMessages, limit, offset); return { - messages: renderableMessages, + messages: page, total, - hasMore: false, - offset: 0, - limit: null, + hasMore, + offset, + limit, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/server/modules/providers/list/gemini/gemini-models.provider.ts b/server/modules/providers/list/gemini/gemini-models.provider.ts index fc830394..d59612cd 100644 --- a/server/modules/providers/list/gemini/gemini-models.provider.ts +++ b/server/modules/providers/list/gemini/gemini-models.provider.ts @@ -12,17 +12,14 @@ import { export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = { OPTIONS: [ - { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' }, - { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, + { value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' }, { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, - { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, - { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' }, - { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, - { value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' }, - { value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }, + { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' }, + { value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' }, + { value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' }, ], - DEFAULT: 'gemini-3.1-pro-preview', + DEFAULT: 'gemini-3-flash-preview', }; export class GeminiProviderModels implements IProviderModels { diff --git a/server/modules/providers/list/gemini/gemini-sessions.provider.ts b/server/modules/providers/list/gemini/gemini-sessions.provider.ts index 32781c9d..4046919a 100644 --- a/server/modules/providers/list/gemini/gemini-sessions.provider.ts +++ b/server/modules/providers/list/gemini/gemini-sessions.provider.ts @@ -5,7 +5,7 @@ import readline from 'node:readline'; import { sessionsDb } from '@/modules/database/index.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; -import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; +import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; const PROVIDER = 'gemini'; @@ -518,9 +518,9 @@ export class GeminiSessionsProvider implements IProviderSessions { const start = Math.max(0, offset); const pageLimit = limit === null ? null : Math.max(0, limit); - const messages = pageLimit === null - ? normalized.slice(start) - : normalized.slice(start, start + pageLimit); + // Tail pagination via the shared contract: offset 0 returns the most + // recent page, matching every other provider. + const { page, hasMore } = sliceTailPage(normalized, pageLimit, start); let total = 0; for (const msg of normalized) { if (msg.kind !== 'tool_result') { @@ -529,9 +529,9 @@ export class GeminiSessionsProvider implements IProviderSessions { } return { - messages, + messages: page, total, - hasMore: pageLimit === null ? false : start + pageLimit < normalized.length, + hasMore, offset: start, limit: pageLimit, tokenUsage: result.tokenUsage, diff --git a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts index bd9ad9fe..ea63c776 100644 --- a/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +++ b/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts @@ -112,7 +112,21 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer } const fallbackTitle = 'Untitled OpenCode Session'; - const existingSession = sessionsDb.getSessionById(sessionId); + const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId) + ?? sessionsDb.getSessionById(sessionId) + ?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath); + if (pendingAppSession && !pendingAppSession.provider_session_id) { + // Slow networks can let the sqlite watcher index opencode.db before the + // runtime reports its provider id back through the websocket mapping. + // Bind that id to the fresh app row first so the watcher does not create + // a temporary provider-id sidebar entry for the same session. + sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId); + } + + // App-created sessions are keyed by an app id, so disk-discovered provider + // ids must be resolved through the provider-id mapping first. + const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId) + ?? sessionsDb.getSessionById(sessionId); const existingName = existingSession?.custom_name; const nextName = existingName && existingName !== fallbackTitle ? existingName @@ -120,7 +134,9 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer // OpenCode stores every session in one shared sqlite database, so jsonl_path // must stay null to avoid deleting opencode.db when one app session is removed. - sessionsDb.createSession( + // Return the canonical stored row id so watcher-triggered sidebar updates + // stay on the app session once provider_session_id has already been mapped. + return sessionsDb.createSession( sessionId, this.provider, projectPath, @@ -129,8 +145,6 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer normalizeProviderTimestamp(row.time_updated ?? row.time_created), null, ); - - return sessionId; } private readFirstUserText(db: Database.Database, sessionId: string): string | undefined { diff --git a/server/modules/providers/list/opencode/opencode-sessions.provider.ts b/server/modules/providers/list/opencode/opencode-sessions.provider.ts index 5b7bcce7..6da4857d 100644 --- a/server/modules/providers/list/opencode/opencode-sessions.provider.ts +++ b/server/modules/providers/list/opencode/opencode-sessions.provider.ts @@ -12,6 +12,7 @@ import { readObjectRecord, readJsonRecord, readOptionalString, + sliceTailPage, } from '@/shared/utils.js'; const PROVIDER = 'opencode'; @@ -325,6 +326,9 @@ export class OpenCodeSessionsProvider implements IProviderSessions { options: FetchHistoryOptions = {}, ): Promise { const { limit = null, offset = 0 } = options; + // OpenCode's shared sqlite database keys messages by the provider-native + // session id, not the app-facing id this method is addressed with. + const providerSessionId = options.providerSessionId ?? sessionId; const db = openOpenCodeDatabase(); if (!db) { return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; @@ -349,27 +353,20 @@ export class OpenCodeSessionsProvider implements IProviderSessions { m.id, COALESCE(p.time_created, 0), p.id - `).all(sessionId) as OpenCodeHistoryRow[]; + `).all(providerSessionId) as OpenCodeHistoryRow[]; const normalized = this.normalizeHistoryRows(rows, sessionId); - const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, sessionId); + const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId); const normalizedOffset = Math.max(0, offset); const normalizedLimit = limit === null ? null : Math.max(0, limit); const total = normalized.length; - const messages = normalizedLimit === null - ? normalized - : normalized.slice( - Math.max(0, total - normalizedOffset - normalizedLimit), - Math.max(0, total - normalizedOffset), - ); + const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset); return { - messages, + messages: page, total, - hasMore: normalizedLimit === null - ? false - : Math.max(0, total - normalizedOffset - normalizedLimit) > 0, + hasMore, offset: normalizedOffset, limit: normalizedLimit, tokenUsage, diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 2604fcc8..ec76a7db 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -1,6 +1,7 @@ import express, { type Request, type Response } from 'express'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; +import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; import { providerModelsService } from '@/modules/providers/services/provider-models.service.js'; import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; @@ -382,7 +383,51 @@ router.post( }), ); +router.get( + '/capabilities', + asyncHandler(async (_req: Request, res: Response) => { + res.json(createApiSuccessResponse({ + providers: providerCapabilitiesService.listAllProviderCapabilities(), + })); + }), +); + +router.get( + '/:provider/capabilities', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + res.json(createApiSuccessResponse( + providerCapabilitiesService.getProviderCapabilities(provider), + )); + }), +); + // ----------------- Session routes ----------------- +/** + * Session gateway entry point: allocates the stable app-facing session id for + * a brand-new chat. The frontend must call this before the first `chat.send` + * so the session id in the URL, the store, and the websocket all agree from + * the very first message — there is no client-visible session-id handoff. + */ +router.post( + '/sessions', + asyncHandler(async (req: Request, res: Response) => { + const body = (req.body ?? {}) as Record; + const provider = parseProvider(body.provider); + const projectPath = typeof body.projectPath === 'string' ? body.projectPath : ''; + const result = sessionsService.createAppSession(provider, projectPath); + res.status(201).json(createApiSuccessResponse(result)); + }), +); + +router.get( + '/sessions/running', + asyncHandler(async (_req: Request, res: Response) => { + const sessions = sessionsService.listRunningSessions(); + res.json(createApiSuccessResponse({ sessions })); + }), +); + router.get( '/sessions/archived', asyncHandler(async (_req: Request, res: Response) => { @@ -459,7 +504,7 @@ router.get( limit, offset, }); - res.json(result); + res.json(createApiSuccessResponse(result)); }), ); diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts new file mode 100644 index 00000000..1b7cbbb3 --- /dev/null +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -0,0 +1,91 @@ +import type { LLMProvider } from '@/shared/types.js'; + +/** + * Static, backend-owned description of what one provider integration supports. + * + * The frontend renders its composer UI (permission mode picker, image upload, + * abort button, ...) purely from this shape, which is what keeps the frontend + * free of per-provider conditionals. New provider features should be exposed + * here instead of branching on the provider id in React components. + */ +type ProviderCapabilities = { + provider: LLMProvider; + /** Permission modes the provider runtime understands, in cycle order. */ + permissionModes: string[]; + defaultPermissionMode: string; + /** Whether image attachments can be included in a chat.send. */ + supportsImages: boolean; + /** Whether an in-flight run can be cancelled via chat.abort. */ + supportsAbort: boolean; + /** Whether interactive tool permission prompts can reach the UI. */ + supportsPermissionRequests: boolean; + /** Whether the token-usage endpoint has data for this provider. */ + supportsTokenUsage: boolean; +}; + +/** + * The capability matrix mirrors what each runtime actually implements today: + * - permission modes match the option sets accepted by each CLI/SDK. + * - only the Claude SDK integration surfaces interactive permission requests. + * - Cursor has no token usage endpoint support (its store.db has no usage rows). + */ +const PROVIDER_CAPABILITIES: Record = { + claude: { + provider: 'claude', + permissionModes: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'], + defaultPermissionMode: 'default', + supportsImages: true, + supportsAbort: true, + supportsPermissionRequests: true, + supportsTokenUsage: true, + }, + cursor: { + provider: 'cursor', + permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: false, + }, + codex: { + provider: 'codex', + permissionModes: ['default', 'acceptEdits', 'bypassPermissions'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: true, + }, + gemini: { + provider: 'gemini', + permissionModes: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: true, + }, + opencode: { + provider: 'opencode', + permissionModes: ['default'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: false, + supportsTokenUsage: true, + }, +}; + +/** + * Application service exposing the provider capability matrix. + */ +export const providerCapabilitiesService = { + getProviderCapabilities(provider: LLMProvider): ProviderCapabilities { + return PROVIDER_CAPABILITIES[provider]; + }, + + listAllProviderCapabilities(): ProviderCapabilities[] { + return Object.values(PROVIDER_CAPABILITIES); + }, +}; diff --git a/server/modules/providers/services/provider-models.service.ts b/server/modules/providers/services/provider-models.service.ts index 9d3402b5..9162df0c 100644 --- a/server/modules/providers/services/provider-models.service.ts +++ b/server/modules/providers/services/provider-models.service.ts @@ -17,7 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js'; export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; const PROVIDER_MODELS_CACHE_VERSION = 1; -const UNCACHED_PROVIDERS = new Set(['claude']); +const UNCACHED_PROVIDERS = new Set(['claude', 'gemini']); type ProviderModelsServiceDependencies = { resolveProvider?: (provider: LLMProvider) => Pick; diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 7e0ab36a..cfbdb887 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -4,10 +4,11 @@ import { promises as fsPromises } from 'node:fs'; import chokidar, { type FSWatcher } from 'chokidar'; +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js'; import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js'; import type { LLMProvider } from '@/shared/types.js'; -import { getProjectsWithSessions } from '@/modules/projects/index.js'; +import { generateDisplayName } from '@/modules/projects/index.js'; type WatcherEventType = 'add' | 'change'; @@ -58,6 +59,11 @@ const watchers: FSWatcher[] = []; type PendingWatcherUpdate = { providers: Set; changeTypes: Set; + /** + * Provider-native session ids reported by the synchronizers. They are + * translated back to app-facing session rows at flush time, because the + * transcript file names on disk only ever contain provider ids. + */ updatedSessionIds: Set; }; @@ -131,6 +137,50 @@ function queuePendingWatcherUpdate( schedulePendingWatcherFlush(); } +/** + * Builds one `session_upserted` delta event for a provider-native session id. + * + * The event carries everything a sidebar needs to upsert the session in place + * (session summary plus owning-project metadata), so clients never need a full + * project-list refetch when a transcript file changes on disk. Returns `null` + * when the id cannot be resolved to an indexed session row. + */ +async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise { + const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId) + ?? sessionsDb.getSessionById(updatedProviderSessionId); + if (!row || row.isArchived) { + return null; + } + + const projectPath = row.project_path; + const project = projectPath ? projectsDb.getProjectPath(projectPath) : null; + const displayName = project?.custom_project_name?.trim() + ? project.custom_project_name + : await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath); + + return JSON.stringify({ + kind: 'session_upserted', + sessionId: row.session_id, + provider: row.provider, + session: { + id: row.session_id, + summary: row.custom_name || '', + messageCount: 0, + lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), + }, + project: project + ? { + projectId: project.project_id, + path: project.project_path, + fullPath: project.project_path, + displayName, + isStarred: Boolean(project.isStarred), + } + : null, + timestamp: new Date().toISOString(), + }); +} + async function flushPendingWatcherUpdate(): Promise { clearPendingWatcherFlushTimer(); @@ -149,33 +199,29 @@ async function flushPendingWatcherUpdate(): Promise { watcherRefreshInFlight = true; try { - const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true }); - const changeTypes = Array.from(queuedUpdate.changeTypes); - const watchProviders = Array.from(queuedUpdate.providers); - const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds); - - // Backward-compatible fields stay populated with the first queued values. - const updateMessage = JSON.stringify({ - type: 'projects_updated', - projects: updatedProjects, - timestamp: new Date().toISOString(), - changeType: changeTypes[0] ?? 'change', - updatedSessionId: updatedSessionIds[0] ?? undefined, - watchProvider: watchProviders[0] ?? undefined, - changeTypes, - updatedSessionIds, - watchProviders, - batched: true, - }); - - connectedClients.forEach(client => { - if (client.readyState === WS_OPEN_STATE) { - client.send(updateMessage); + // Per-session deltas instead of full project snapshots: an upsert of one + // session can never clobber unrelated client state, so the frontend needs + // no "suppress updates while a run is active" protection logic. + const events: string[] = []; + for (const updatedSessionId of queuedUpdate.updatedSessionIds) { + const event = await buildSessionUpsertedEvent(updatedSessionId); + if (event) { + events.push(event); } - }); + } + + if (events.length > 0) { + connectedClients.forEach(client => { + if (client.readyState === WS_OPEN_STATE) { + for (const event of events) { + client.send(event); + } + } + }); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message }); + console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message }); } finally { watcherRefreshInFlight = false; diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 49b5dcb7..6836e87e 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -1,7 +1,9 @@ +import { randomUUID } from 'node:crypto'; import fsp from 'node:fs/promises'; import path from 'node:path'; import { projectsDb, sessionsDb } from '@/modules/database/index.js'; +import { chatRunRegistry } from '@/modules/websocket/index.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js'; import type { FetchHistoryOptions, @@ -11,6 +13,12 @@ import type { } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; +type CreateAppSessionResult = { + sessionId: string; + provider: LLMProvider; + projectPath: string; +}; + type ArchivedSessionListItem = { sessionId: string; provider: LLMProvider; @@ -77,6 +85,21 @@ export const sessionsService = { return providerRegistry.listProviders().map((provider) => provider.id); }, + /** + * Returns app-facing ids for provider runs that are currently processing. + * + * This is intentionally status-only: callers that only need sidebar activity + * indicators should not attach to chat streams or request replayed messages. + */ + listRunningSessions(): Array<{ + sessionId: string; + provider: LLMProvider; + startedAt: number; + lastSeq: number; + }> { + return chatRunRegistry.listRunningRuns(); + }, + /** * Normalizes one provider-native event into frontend session message events. */ @@ -89,12 +112,43 @@ export const sessionsService = { }, /** - * Fetches persisted history by session id. + * Allocates a stable app-facing session id before any provider run happens. + * + * This is the entry point of the session gateway: the frontend calls this + * (via `POST /api/providers/sessions`) when the user starts a brand-new + * chat, navigates to the returned id immediately, and the id never changes + * for the lifetime of the conversation. The provider-native id is mapped to + * this row later, when the provider runtime announces it mid-run. + */ + createAppSession(provider: LLMProvider, projectPath: string): CreateAppSessionResult { + const normalizedProjectPath = projectPath.trim(); + if (!normalizedProjectPath) { + throw new AppError('projectPath is required.', { + code: 'PROJECT_PATH_REQUIRED', + statusCode: 400, + }); + } + + const sessionId = randomUUID(); + sessionsDb.createAppSession(sessionId, provider, normalizedProjectPath); + + return { + sessionId, + provider, + projectPath: normalizedProjectPath, + }; + }, + + /** + * Fetches persisted history by app session id. * * Provider and provider-specific lookup hints are resolved from the indexed - * session metadata in the database. + * session metadata in the database. The provider adapter receives the + * provider-native session id (the one written into transcripts on disk), + * and every returned message is remapped back to the app session id so + * provider ids never reach the frontend. */ - fetchHistory( + async fetchHistory( sessionId: string, options: Pick = {}, ): Promise { @@ -106,12 +160,33 @@ export const sessionsService = { }); } + // App-created sessions that never produced a provider transcript yet + // (e.g. first message still streaming) simply have no history. + if (!session.provider_session_id) { + return { + messages: [], + total: 0, + hasMore: false, + offset: options.offset ?? 0, + limit: options.limit ?? null, + }; + } + const provider = session.provider as LLMProvider; - return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, { + const result = await providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, { limit: options.limit ?? null, offset: options.offset ?? 0, projectPath: session.project_path ?? '', + providerSessionId: session.provider_session_id, }); + + return { + ...result, + messages: result.messages.map((message) => ({ + ...message, + sessionId, + })), + }; }, /** diff --git a/server/modules/providers/tests/opencode-sessions.test.ts b/server/modules/providers/tests/opencode-sessions.test.ts index d5b65e4e..2a98ea6f 100644 --- a/server/modules/providers/tests/opencode-sessions.test.ts +++ b/server/modules/providers/tests/opencode-sessions.test.ts @@ -272,6 +272,55 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr } }); +test('OpenCode session synchronizer returns the app session id once provider mapping exists', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-mapped-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await createOpenCodeDatabase(tempRoot, workspacePath); + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-session-1', 'opencode', workspacePath); + sessionsDb.assignProviderSessionId('app-session-1', 'open-session-1'); + + const synchronizer = new OpenCodeSessionSynchronizer(); + return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => { + assert.equal(sessionId, 'app-session-1'); + assert.equal(sessionsDb.getAllSessions().length, 1); + assert.equal(sessionsDb.getSessionById('app-session-1')?.provider_session_id, 'open-session-1'); + }); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('OpenCode session synchronizer adopts the pending app session before watcher sync creates a duplicate', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-race-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await createOpenCodeDatabase(tempRoot, workspacePath); + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-session-race', 'opencode', workspacePath); + + const synchronizer = new OpenCodeSessionSynchronizer(); + return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => { + assert.equal(sessionId, 'app-session-race'); + assert.equal(sessionsDb.getAllSessions().length, 1); + assert.equal(sessionsDb.getSessionById('app-session-race')?.provider_session_id, 'open-session-1'); + }); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => { const provider = new OpenCodeSessionsProvider(); const normalized = provider.normalizeMessage({ diff --git a/server/modules/websocket/README.md b/server/modules/websocket/README.md index 12db6349..660e36ab 100644 --- a/server/modules/websocket/README.md +++ b/server/modules/websocket/README.md @@ -33,10 +33,12 @@ Benefits: |---|---| | `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname | | `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` | -| `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages | +| `services/chat-websocket.service.ts` | Handles the `/ws` chat protocol (`chat.send` / `chat.abort` / `chat.subscribe` / `chat.permission-response`) | +| `services/chat-run-registry.service.ts` | Tracks live provider runs per app session id: seq numbering, event replay buffer, provider-id mapping, completion state | +| `services/chat-session-writer.service.ts` | Gateway writer handed to provider runtimes: remaps provider session ids to app ids, swallows `session_created`, assigns `seq` | | `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection | | `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket | -| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) | +| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) for non-chat writer consumers | | `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant | ## High-Level Architecture @@ -52,12 +54,12 @@ flowchart LR D -->|other| H[close()] E --> I[connectedClients Set] - E --> J[WebSocketWriter] + E --> J[chatRunRegistry + ChatSessionWriter] F --> K[ptySessionsMap] G --> L[Upstream Plugin ws://127.0.0.1:port/ws] - I --> M[projects.service broadcastProgress] - I --> N[sessions-watcher.service projects_updated] + I --> M[projects.service loading_progress] + I --> N[sessions-watcher.service session_upserted] ``` ## Connection Handshake + Routing @@ -105,37 +107,41 @@ sequenceDiagram When a chat socket connects: 1. Add socket to `connectedClients`. -2. Build `WebSocketWriter` (captures `userId` from authenticated request). -3. Parse each incoming message with `parseIncomingJsonObject`. -4. Dispatch by `data.type`. -5. On close, remove socket from `connectedClients`. +2. Parse each incoming message with `parseIncomingJsonObject`. +3. Dispatch by `data.type` (four message types, none provider-specific). +4. On close, remove socket from `connectedClients`. + +### Session identity model + +The frontend only ever knows the **app session id** (allocated by +`POST /api/providers/sessions` or discovered via the session index). The +provider-native id (JSONL file name, CLI resume id) stays inside the backend: + +1. `chat.send` resolves the app id to `{ provider, provider_session_id, project_path }` from the sessions DB. +2. The provider runtime receives the provider-native id for resume. +3. The `ChatSessionWriter` remaps every outbound event back to the app id, and turns `session_created` announcements into a DB mapping update instead of forwarding them. ### Chat Message Dispatch ```mermaid flowchart TD A[Incoming WS message] --> B[parseIncomingJsonObject] - B -->|invalid| C[send {type:error}] + B -->|invalid| C[send kind:protocol_error] B -->|ok| D{data.type} - D -->|claude-command| E[queryClaudeSDK] - D -->|cursor-command| F[spawnCursor] - D -->|codex-command| G[queryCodex] - D -->|gemini-command| H[spawnGemini] - D -->|cursor-resume| I[spawnCursor resume] - D -->|abort-session| J[abort by provider] - D -->|claude-permission-response| K[resolveToolApproval] - D -->|cursor-abort| L[abortCursorSession] - D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter] - D -->|get-pending-permissions| N[getPendingApprovalsForSession] - D -->|get-active-sessions| O[getActive*Sessions] + D -->|chat.send| E[resolve session row -> startRun -> spawnFns provider] + D -->|chat.abort| F[abortFns provider + synthetic complete] + D -->|chat.subscribe| G[chat_subscribed ack + attach socket + replay events seq > lastSeq] + D -->|chat.permission-response| H[resolveToolApproval] + D -->|other| I[send kind:protocol_error] ``` ### Chat Notes -1. `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 envelope**: every server-to-client frame carries a `kind` — either a provider `NormalizedMessage` kind or a gateway kind (`chat_subscribed`, `session_upserted`, `loading_progress`, `protocol_error`). There is no second `type`-based protocol. +2. **Unified terminal lifecycle**: every provider run ends with exactly one `complete` message built by `createCompleteMessage()` (`server/shared/utils.ts`): `{ kind: "complete", sessionId, actualSessionId, exitCode, success, aborted }`. The chat handler emits a synthetic `complete` for runs that crash or get aborted, and the run registry drops duplicate completes. +3. **Per-run event log**: every live event gets a monotonically increasing `seq`. `chat.subscribe { sessions: [{ sessionId, lastSeq }] }` re-attaches the live stream to the requesting socket (any provider, not just Claude) and replays events with `seq > lastSeq`. If the buffer no longer covers `lastSeq`, the client refreshes over REST. +4. `chat_subscribed` includes `isProcessing` (replaces `check-session-status`) and `pendingPermissions` (replaces `get-pending-permissions`). ## `/shell` Terminal Flow @@ -223,9 +229,9 @@ Only chat sockets (`/ws`) are tracked in `connectedClients`. That shared set is consumed by: 1. `modules/projects/services/projects-with-sessions-fetch.service.ts` -Broadcasts `loading_progress` while project snapshots are being built. +Broadcasts `kind: loading_progress` while project snapshots are being built. 2. `modules/providers/services/sessions-watcher.service.ts` -Broadcasts `projects_updated` when provider session artifacts change. +Broadcasts per-session `kind: session_upserted` deltas when provider session artifacts change (no full project snapshots). This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals. @@ -252,7 +258,7 @@ Current explicit close codes in this module: Other errors: -1. Chat handler catches and emits `{ type: "error", error }`. +1. Chat handler catches and emits `{ kind: "protocol_error", code, error }`. 2. Shell handler catches and writes terminal-visible error output. 3. Unknown websocket paths are closed immediately. diff --git a/server/modules/websocket/index.ts b/server/modules/websocket/index.ts index da65ee82..56bf1d00 100644 --- a/server/modules/websocket/index.ts +++ b/server/modules/websocket/index.ts @@ -1,2 +1,3 @@ export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js'; export { createWebSocketServer } from './services/websocket-server.service.js'; +export { chatRunRegistry } from './services/chat-run-registry.service.js'; diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts new file mode 100644 index 00000000..a5e51b5f --- /dev/null +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -0,0 +1,327 @@ +import path from 'node:path'; + +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; +import { generateDisplayName } from '@/modules/projects/index.js'; +import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js'; +import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; +import type { + LLMProvider, + NormalizedMessage, + RealtimeClientConnection, +} from '@/shared/types.js'; + +type ChatRunStatus = 'running' | 'completed'; + +/** + * One live (or recently finished) provider run for a single app session. + * + * State notes — why each mutable field is essential: + * - `providerSessionId`: the provider-native id captured mid-run. The abort + * handler needs it to address the provider runtime, and the DB mapping is + * written from it so history/resume work after the run. + * - `status`: drives `chat_subscribed.isProcessing`, prevents double sends + * into the same session, and guards the synthetic-complete fallback in the + * chat handler (only emitted when a runtime died without completing). + * - `lastSeq` / `events`: the per-run event log. Every live event gets a + * monotonically increasing `seq` and is buffered so a reconnecting client + * can replay exactly the events it missed via `chat.subscribe`. + */ +type ChatRun = { + appSessionId: string; + provider: LLMProvider; + providerSessionId: string | null; + status: ChatRunStatus; + lastSeq: number; + events: NormalizedMessage[]; + writer: ChatSessionWriter; + startedAt: number; + completedAt: number | null; +}; + +/** + * How long a completed run stays available for replay. Covers the window + * between a run finishing and the client refreshing history over REST (for + * example when the browser tab was asleep while the run completed). + */ +const COMPLETED_RUN_RETENTION_MS = 5 * 60 * 1000; + +/** + * Upper bound on buffered events per run so a very long tool-heavy run cannot + * grow memory unbounded. When exceeded, the oldest events are dropped — + * a reconnecting client whose `lastSeq` predates the buffer falls back to a + * REST history refresh, which is always the authoritative source. + */ +const MAX_BUFFERED_EVENTS_PER_RUN = 5000; + +/** + * Active and recently-completed runs keyed by app session id. + * + * This map is the single in-memory source of truth for "is something running + * for this session" — the chat websocket handler, abort path, and subscribe + * path all consult it instead of asking each provider runtime individually. + */ +const runs = new Map(); + +async function broadcastCanonicalSessionUpsert(appSessionId: string): Promise { + const row = sessionsDb.getSessionById(appSessionId); + if (!row || row.isArchived) { + return; + } + + const projectPath = row.project_path; + const project = projectPath ? projectsDb.getProjectPath(projectPath) : null; + const displayName = project?.custom_project_name?.trim() + ? project.custom_project_name + : await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath); + + const payload = JSON.stringify({ + kind: 'session_upserted', + sessionId: row.session_id, + providerSessionId: row.provider_session_id, + provider: row.provider, + session: { + id: row.session_id, + summary: row.custom_name || '', + messageCount: 0, + lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), + }, + project: project + ? { + projectId: project.project_id, + path: project.project_path, + fullPath: project.project_path, + displayName, + isStarred: Boolean(project.isStarred), + } + : null, + timestamp: new Date().toISOString(), + }); + + connectedClients.forEach((client) => { + if (client.readyState === WS_OPEN_STATE) { + client.send(payload); + } + }); +} + +function evictRunLater(appSessionId: string): void { + const timer = setTimeout(() => { + const run = runs.get(appSessionId); + if (run && run.status === 'completed') { + runs.delete(appSessionId); + } + }, COMPLETED_RUN_RETENTION_MS); + + // Never keep the process alive just to evict a buffered run. + timer.unref?.(); +} + +/** + * Decorates one outbound live event for a run and records it in the event log. + * + * Responsibilities: + * 1. Remap `sessionId` (and `actualSessionId` on `complete`) to the stable + * app session id — provider-native ids never leave the backend. + * 2. Assign the next `seq` so clients can detect/replay gaps. + * 3. Buffer the event for `chat.subscribe` replay. + * 4. Flip the run to `completed` when the terminal `complete` event passes by. + */ +function decorateAndRecordEvent(run: ChatRun, message: NormalizedMessage): NormalizedMessage | null { + // Exactly-one-complete contract: when a run is aborted the chat handler + // emits the terminal `complete` immediately, but the killed runtime may + // still emit its own `complete` from its exit handler moments later. + // Whichever arrives first wins; the duplicate is dropped here. + if (message.kind === 'complete' && run.status === 'completed') { + return null; + } + + run.lastSeq += 1; + + const outbound: NormalizedMessage = { + ...message, + sessionId: run.appSessionId, + seq: run.lastSeq, + }; + + if (message.kind === 'complete') { + // The provider may report its own id here; the frontend only ever knows + // the app id, so the "actual" id is by definition the app id as well. + outbound.actualSessionId = run.appSessionId; + run.status = 'completed'; + run.completedAt = Date.now(); + evictRunLater(run.appSessionId); + } + + run.events.push(outbound); + if (run.events.length > MAX_BUFFERED_EVENTS_PER_RUN) { + run.events.splice(0, run.events.length - MAX_BUFFERED_EVENTS_PER_RUN); + } + + return outbound; +} + +/** + * Records the provider-native session id for a run and persists the + * app-id-to-provider-id mapping so history fetches and future resumes can + * address the provider transcript. + * + * Called from the gateway writer when the runtime either calls + * `setSessionId(...)` or emits its `session_created` event — whichever + * happens first wins; later calls with the same id are no-ops. + */ +function recordProviderSessionId(run: ChatRun, providerSessionId: string): void { + if (!providerSessionId || run.providerSessionId === providerSessionId) { + return; + } + + run.providerSessionId = providerSessionId; + + try { + sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId); + void broadcastCanonicalSessionUpsert(run.appSessionId).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error('[ChatRunRegistry] Failed to broadcast canonical session mapping', { + appSessionId: run.appSessionId, + providerSessionId, + error: message, + }); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[ChatRunRegistry] Failed to persist provider session id mapping', { + appSessionId: run.appSessionId, + providerSessionId, + error: message, + }); + } +} + +/** + * Registry of live provider runs keyed by the stable app session id. + * + * The registry is what makes the websocket protocol provider-independent: + * every run gets a `ChatSessionWriter` that remaps provider-native session + * ids to the app id, assigns `seq` numbers, and buffers events for replay — + * regardless of which provider runtime produced them. + */ +export const chatRunRegistry = { + /** + * Starts tracking a run and returns it, or `null` when a run is already in + * progress for the session (callers must reject the duplicate send). + */ + startRun(input: { + appSessionId: string; + provider: LLMProvider; + providerSessionId: string | null; + connection: RealtimeClientConnection; + userId: string | number | null; + }): ChatRun | null { + const existing = runs.get(input.appSessionId); + if (existing && existing.status === 'running') { + return null; + } + + const run: ChatRun = { + appSessionId: input.appSessionId, + provider: input.provider, + providerSessionId: input.providerSessionId, + status: 'running', + lastSeq: 0, + events: [], + writer: null as unknown as ChatSessionWriter, + startedAt: Date.now(), + completedAt: null, + }; + + run.writer = new ChatSessionWriter({ + connection: input.connection, + userId: input.userId, + provider: input.provider, + providerSessionId: input.providerSessionId, + onProviderSessionId: (providerSessionId) => { + recordProviderSessionId(run, providerSessionId); + }, + decorateOutboundEvent: (message) => decorateAndRecordEvent(run, message), + }); + + runs.set(input.appSessionId, run); + return run; + }, + + getRun(appSessionId: string): ChatRun | undefined { + return runs.get(appSessionId); + }, + + isProcessing(appSessionId: string): boolean { + return runs.get(appSessionId)?.status === 'running'; + }, + + listRunningRuns(): Array<{ + sessionId: string; + provider: LLMProvider; + startedAt: number; + lastSeq: number; + }> { + return Array.from(runs.values()) + .filter((run) => run.status === 'running') + .map((run) => ({ + sessionId: run.appSessionId, + provider: run.provider, + startedAt: run.startedAt, + lastSeq: run.lastSeq, + })); + }, + + /** + * Re-attaches a run's outbound stream to a (new) websocket connection. + * + * This is the generic replacement for the Claude-only writer reconnect: + * after a page refresh the new socket subscribes and immediately starts + * receiving the still-running stream, for every provider. + */ + attachConnection(appSessionId: string, connection: RealtimeClientConnection): boolean { + const run = runs.get(appSessionId); + if (!run) { + return false; + } + + run.writer.updateWebSocket(connection); + return true; + }, + + /** + * Returns buffered events with `seq` greater than `afterSeq` for replay. + * + * An empty array with `run.lastSeq > afterSeq` not covered by the buffer + * means the buffer was truncated; the client should refresh over REST. + */ + replayEvents(appSessionId: string, afterSeq: number): NormalizedMessage[] { + const run = runs.get(appSessionId); + if (!run) { + return []; + } + + return run.events.filter((event) => typeof event.seq === 'number' && event.seq > afterSeq); + }, + + /** + * Emits a synthetic terminal `complete` if (and only if) the run is still + * marked running. Used when a provider runtime throws or resolves without + * having produced its own terminal event, and by the abort path. + */ + completeRun(appSessionId: string, opts: { exitCode: number; aborted?: boolean }): void { + const run = runs.get(appSessionId); + if (!run || run.status !== 'running') { + return; + } + + run.writer.sendComplete(opts); + }, + + /** + * Test-only escape hatch: clears every tracked run. + */ + clearAll(): void { + runs.clear(); + }, +}; diff --git a/server/modules/websocket/services/chat-session-writer.service.ts b/server/modules/websocket/services/chat-session-writer.service.ts new file mode 100644 index 00000000..82805195 --- /dev/null +++ b/server/modules/websocket/services/chat-session-writer.service.ts @@ -0,0 +1,145 @@ +import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; +import type { + LLMProvider, + NormalizedMessage, + RealtimeClientConnection, +} from '@/shared/types.js'; +import { createCompleteMessage, readObjectRecord } from '@/shared/utils.js'; + +type ChatSessionWriterOptions = { + connection: RealtimeClientConnection; + userId: string | number | null; + provider: LLMProvider; + /** Provider-native id when resuming an existing session, otherwise null. */ + providerSessionId: string | null; + /** + * Invoked the moment the provider runtime reveals its native session id + * (either via `setSessionId` or a `session_created` event). The registry + * persists the app-id-to-provider-id mapping from this callback. + */ + onProviderSessionId: (providerSessionId: string) => void; + /** + * Remaps/sequences/buffers one outbound live event. Implemented by the chat + * run registry; the writer never forwards a provider event untouched. + * Returns `null` when the event must be dropped (duplicate terminal + * `complete` after an abort already completed the run). + */ + decorateOutboundEvent: (message: NormalizedMessage) => NormalizedMessage | null; +}; + +/** + * Gateway writer handed to provider runtimes instead of a raw websocket writer. + * + * It exposes the exact same surface as `WebSocketWriter` (`send`, + * `setSessionId`, `getSessionId`, `updateWebSocket`, `userId`, + * `isWebSocketWriter`) so the provider runtimes (`claude-sdk.js`, + * `cursor-cli.js`, ...) need zero changes — but everything that flows through + * it is translated from the provider's world into the app's protocol: + * + * - `session_created` events are swallowed and turned into a provider-id + * mapping; the frontend never learns provider-native ids. + * - every other event gets `sessionId` remapped to the app session id and a + * per-run `seq` assigned before being forwarded. + * - `setSessionId(...)` calls (used by runtimes to label captured ids) are + * intercepted and recorded as the provider-id mapping as well. + */ +export class ChatSessionWriter { + ws: RealtimeClientConnection; + userId: string | number | null; + /** + * Some runtimes feature-detect their writer with this flag; keep it so the + * gateway writer is a drop-in replacement for `WebSocketWriter`. + */ + isWebSocketWriter = true; + + private readonly options: ChatSessionWriterOptions; + /** + * The provider-native session id as the runtime knows it. Kept locally + * (besides the registry) because runtimes read it back via `getSessionId()` + * to label their own outgoing events — those labels are remapped on send + * anyway, but the runtime-visible value must stay provider-native. + */ + private providerSessionId: string | null; + + constructor(options: ChatSessionWriterOptions) { + this.options = options; + this.ws = options.connection; + this.userId = options.userId; + this.providerSessionId = options.providerSessionId; + } + + send(data: unknown): void { + const record = readObjectRecord(data); + if (!record || typeof record.kind !== 'string') { + // Provider runtimes only emit kind-based normalized messages. Anything + // else indicates a programming error; drop it rather than leaking an + // un-remapped payload to the client. + console.error('[ChatSessionWriter] Dropping non-normalized outbound payload', data); + return; + } + + const message = record as NormalizedMessage; + + if (message.kind === 'session_created') { + const announcedId = + typeof message.newSessionId === 'string' && message.newSessionId + ? message.newSessionId + : message.sessionId; + if (announcedId) { + this.captureProviderSessionId(announcedId); + } + // Swallowed on purpose: the frontend already has the stable app session + // id, so there is no client-side handoff to perform anymore. + return; + } + + const outbound = this.options.decorateOutboundEvent(message); + if (outbound) { + this.forward(outbound); + } + } + + /** + * Emits the synthetic terminal `complete` for runs that ended without one + * (runtime crash before completing, or user abort). + */ + sendComplete(opts: { exitCode: number; aborted?: boolean }): void { + const message = createCompleteMessage({ + provider: this.options.provider, + sessionId: this.providerSessionId, + exitCode: opts.exitCode, + aborted: opts.aborted, + }); + const outbound = this.options.decorateOutboundEvent(message); + if (outbound) { + this.forward(outbound); + } + } + + updateWebSocket(newConnection: RealtimeClientConnection): void { + this.ws = newConnection; + } + + setSessionId(sessionId: string): void { + this.captureProviderSessionId(sessionId); + } + + getSessionId(): string | null { + return this.providerSessionId; + } + + private captureProviderSessionId(providerSessionId: string): void { + if (!providerSessionId || this.providerSessionId === providerSessionId) { + return; + } + + this.providerSessionId = providerSessionId; + this.options.onProviderSessionId(providerSessionId); + } + + private forward(message: NormalizedMessage): void { + if (this.ws.readyState === WS_OPEN_STATE) { + this.ws.send(JSON.stringify(message)); + } + } +} diff --git a/server/modules/websocket/services/chat-websocket.service.ts b/server/modules/websocket/services/chat-websocket.service.ts index 829cb0b0..4e676ec3 100644 --- a/server/modules/websocket/services/chat-websocket.service.ts +++ b/server/modules/websocket/services/chat-websocket.service.ts @@ -1,40 +1,35 @@ import type { WebSocket } from 'ws'; -import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js'; -import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js'; +import { sessionsDb } from '@/modules/database/index.js'; +import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; +import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; import type { AnyRecord, AuthenticatedWebSocketRequest, LLMProvider, } from '@/shared/types.js'; -import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js'; +import { parseIncomingJsonObject } from '@/shared/utils.js'; -type ChatIncomingMessage = AnyRecord & { - type?: string; - command?: string; - options?: AnyRecord; - provider?: string; - sessionId?: string; - requestId?: string; - allow?: unknown; - updatedInput?: unknown; - message?: unknown; - rememberEntry?: unknown; -}; - -const DEFAULT_PROVIDER: LLMProvider = 'claude'; +/** + * One provider runtime entry point. All five runtimes share this signature, + * which lets the chat handler dispatch through a provider-keyed map instead + * of provider-specific branches. + */ +type ProviderSpawnFn = ( + command: string, + options: AnyRecord, + writer: unknown +) => Promise; type ChatWebSocketDependencies = { - queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise; - abortClaudeSDKSession: (sessionId: string) => Promise; - abortCursorSession: (sessionId: string) => boolean; - abortCodexSession: (sessionId: string) => boolean; - abortGeminiSession: (sessionId: string) => boolean; - abortOpenCodeSession: (sessionId: string) => boolean; + /** Provider runtimes keyed by provider id. */ + spawnFns: Record; + /** + * Abort functions keyed by provider id. They are addressed with the + * provider-native session id (that is how runtimes key their process maps). + * The Claude abort is async; the rest are sync — both shapes are accepted. + */ + abortFns: Record boolean | Promise>; resolveToolApproval: ( requestId: string, payload: { @@ -44,31 +39,10 @@ type ChatWebSocketDependencies = { rememberEntry?: unknown; } ) => void; - isClaudeSDKSessionActive: (sessionId: string) => boolean; - isCursorSessionActive: (sessionId: string) => boolean; - isCodexSessionActive: (sessionId: string) => boolean; - isGeminiSessionActive: (sessionId: string) => boolean; - isOpenCodeSessionActive: (sessionId: string) => boolean; - reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean; - getPendingApprovalsForSession: (sessionId: string) => unknown[]; - getActiveClaudeSDKSessions: () => unknown; - getActiveCursorSessions: () => unknown; - getActiveCodexSessions: () => unknown; - getActiveGeminiSessions: () => unknown; - getActiveOpenCodeSessions: () => unknown; + /** Claude-only today: pending tool approvals included in `chat_subscribed`. */ + getPendingApprovalsForSession: (providerSessionId: string) => unknown[]; }; -/** - * Normalizes potentially invalid provider names coming from websocket payloads. - */ -function readProvider(value: unknown): LLMProvider { - if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') { - return value; - } - - return DEFAULT_PROVIDER; -} - /** * Extracts the authenticated request user id in the formats currently produced * by platform and OSS auth code paths. @@ -92,8 +66,258 @@ function readRequestUserId( return null; } +function sendJson(ws: WebSocket, payload: unknown): void { + if (ws.readyState === WS_OPEN_STATE) { + ws.send(JSON.stringify(payload)); + } +} + +/** + * Reports a protocol-level failure to the requesting client. + * + * Protocol errors deliberately use their own `kind` (instead of the provider + * `error` message kind) so the frontend can distinguish "your request was + * invalid" from "the model run produced an error" without inspecting text. + */ +function sendProtocolError( + ws: WebSocket, + code: string, + error: string, + sessionId?: string +): void { + sendJson(ws, { + kind: 'protocol_error', + code, + error, + sessionId: sessionId ?? null, + timestamp: new Date().toISOString(), + }); +} + +function readRequiredSessionId(data: AnyRecord): string | null { + const sessionId = typeof data.sessionId === 'string' ? data.sessionId.trim() : ''; + return sessionId.length > 0 ? sessionId : null; +} + +/** + * Handles `chat.send`: resolves the session row (provider, project path, and + * provider-native id all come from the database — never from the client), + * registers the run, and dispatches to the provider runtime. + */ +async function handleChatSend( + ws: WebSocket, + userId: string | number | null, + data: AnyRecord, + dependencies: ChatWebSocketDependencies +): Promise { + const sessionId = readRequiredSessionId(data); + if (!sessionId) { + sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.send requires a sessionId.'); + return; + } + + const session = sessionsDb.getSessionById(sessionId); + if (!session) { + sendProtocolError( + ws, + 'SESSION_NOT_FOUND', + `Session "${sessionId}" was not found. Create it via POST /api/providers/sessions first.`, + sessionId + ); + return; + } + + const provider = session.provider as LLMProvider; + const spawnFn = dependencies.spawnFns[provider]; + if (!spawnFn) { + sendProtocolError(ws, 'UNSUPPORTED_PROVIDER', `Provider "${provider}" is not available.`, sessionId); + return; + } + + const run = chatRunRegistry.startRun({ + appSessionId: sessionId, + provider, + providerSessionId: session.provider_session_id, + connection: ws, + userId, + }); + + if (!run) { + sendProtocolError( + ws, + 'RUN_IN_PROGRESS', + `Session "${sessionId}" already has a run in progress.`, + sessionId + ); + return; + } + + const clientOptions = (data.options ?? {}) as AnyRecord; + const command = typeof data.content === 'string' ? data.content : ''; + + // The provider runtimes receive the provider-native session id (that is the + // id their CLI/SDK understands for resume). Brand-new sessions have no + // provider id yet, so the runtime starts fresh and announces one, which the + // gateway writer captures and maps back to the app session id. + const runtimeOptions: AnyRecord = { + ...clientOptions, + sessionId: session.provider_session_id ?? undefined, + resume: Boolean(session.provider_session_id), + cwd: clientOptions.cwd ?? session.project_path ?? undefined, + projectPath: session.project_path ?? clientOptions.projectPath, + }; + + try { + await spawnFn(command, runtimeOptions, run.writer); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[Chat] Provider runtime "${provider}" failed`, { sessionId, error: message }); + } finally { + // Safety net: a runtime that crashed (or resolved) without emitting its + // terminal `complete` would otherwise leave the session stuck in + // "processing" forever on every connected client. + chatRunRegistry.completeRun(sessionId, { exitCode: 1 }); + } +} + +/** + * Handles `chat.abort`: cancels the run for one app session and emits the + * terminal `complete` on its behalf (runtimes skip their own complete for + * aborted runs, and the registry drops any duplicate). + */ +async function handleChatAbort( + ws: WebSocket, + data: AnyRecord, + dependencies: ChatWebSocketDependencies +): Promise { + const sessionId = readRequiredSessionId(data); + if (!sessionId) { + sendProtocolError(ws, 'SESSION_ID_REQUIRED', 'chat.abort requires a sessionId.'); + return; + } + + const run = chatRunRegistry.getRun(sessionId); + if (!run || run.status !== 'running') { + sendProtocolError(ws, 'NO_ACTIVE_RUN', `Session "${sessionId}" has no active run.`, sessionId); + return; + } + + const abortFn = dependencies.abortFns[run.provider]; + let success = false; + if (abortFn && run.providerSessionId) { + success = Boolean(await abortFn(run.providerSessionId)); + } + + chatRunRegistry.completeRun(sessionId, { + exitCode: success ? 0 : 1, + aborted: true, + }); +} + +/** + * Handles `chat.subscribe`: for each requested session, reports whether a run + * is processing, re-attaches the live stream to this socket, replays missed + * events (seq > lastSeq), and includes pending permission requests. + * + * This single message replaces the old `check-session-status`, + * `get-pending-permissions`, and Claude-only writer reconnect flows. + */ +function handleChatSubscribe( + ws: WebSocket, + data: AnyRecord, + dependencies: ChatWebSocketDependencies +): void { + const targets = Array.isArray(data.sessions) ? data.sessions : []; + + for (const target of targets) { + if (!target || typeof target !== 'object') { + continue; + } + + const sessionId = typeof (target as AnyRecord).sessionId === 'string' + ? ((target as AnyRecord).sessionId as string).trim() + : ''; + if (!sessionId) { + continue; + } + + const lastSeqRaw = (target as AnyRecord).lastSeq; + const lastSeq = typeof lastSeqRaw === 'number' && Number.isFinite(lastSeqRaw) + ? Math.max(0, Math.floor(lastSeqRaw)) + : 0; + + const run = chatRunRegistry.getRun(sessionId); + const isProcessing = chatRunRegistry.isProcessing(sessionId); + + // Future live events for this run should land on the socket that asked — + // this is what makes mid-stream page refreshes work for all providers. + if (isProcessing) { + chatRunRegistry.attachConnection(sessionId, ws); + } + + // Pending approvals are tracked under the provider-native id inside the + // Claude runtime; remap their sessionId so the client only sees app ids. + const pendingPermissions = (run?.providerSessionId + ? dependencies.getPendingApprovalsForSession(run.providerSessionId) + : [] + ).map((approval) => + approval && typeof approval === 'object' + ? { ...(approval as AnyRecord), sessionId } + : approval, + ); + + sendJson(ws, { + kind: 'chat_subscribed', + sessionId, + isProcessing, + lastSeq: run?.lastSeq ?? 0, + pendingPermissions, + timestamp: new Date().toISOString(), + }); + + // Replay only for RUNNING runs, strictly after the ack. Completed runs + // are fully persisted to the provider transcript and served over REST — + // replaying them (e.g. after a page reload where the client's lastSeq is + // 0) would duplicate messages the history fetch already returned. + if (isProcessing) { + for (const event of chatRunRegistry.replayEvents(sessionId, lastSeq)) { + sendJson(ws, event); + } + } + } +} + +/** + * Handles `chat.permission-response`: forwards a tool-approval decision to the + * pending approval resolver (Claude is the only provider with interactive + * approvals today, but the message is intentionally provider-neutral). + */ +function handlePermissionResponse(data: AnyRecord, dependencies: ChatWebSocketDependencies): void { + if (typeof data.requestId !== 'string' || data.requestId.length === 0) { + return; + } + + dependencies.resolveToolApproval(data.requestId, { + allow: Boolean(data.allow), + updatedInput: data.updatedInput, + message: typeof data.message === 'string' ? data.message : undefined, + rememberEntry: data.rememberEntry, + }); +} + /** * Handles authenticated chat websocket messages used by the main chat panel. + * + * Inbound protocol (client to server): + * - `chat.send` { sessionId, content, options? } + * - `chat.abort` { sessionId } + * - `chat.subscribe` { sessions: [{ sessionId, lastSeq? }] } + * - `chat.permission-response` { requestId, allow, updatedInput?, message?, rememberEntry? } + * + * Outbound protocol (server to client): every frame is `kind`-based — either + * a provider `NormalizedMessage` (with `seq`) or a gateway event + * (`chat_subscribed`, `session_upserted`, `loading_progress`, + * `protocol_error`). */ export function handleChatConnection( ws: WebSocket, @@ -103,7 +327,7 @@ export function handleChatConnection( console.log('[INFO] Chat WebSocket connected'); connectedClients.add(ws); - const writer = new WebSocketWriter(ws, readRequestUserId(request)); + const userId = readRequestUserId(request); ws.on('message', async (rawMessage) => { try { @@ -112,169 +336,30 @@ export function handleChatConnection( throw new Error('Invalid websocket payload'); } - const data = parsed as ChatIncomingMessage; - const messageType = data.type; - if (!messageType) { - throw new Error('Message type is required'); - } + const data = parsed as AnyRecord; + const messageType = typeof data.type === 'string' ? data.type : ''; - if (messageType === 'claude-command') { - await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'cursor-command') { - await dependencies.spawnCursor(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'codex-command') { - await dependencies.queryCodex(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'gemini-command') { - await dependencies.spawnGemini(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'opencode-command') { - await dependencies.spawnOpenCode(data.command ?? '', data.options, writer); - return; - } - - if (messageType === 'cursor-resume') { - await dependencies.spawnCursor( - '', - { - sessionId: data.sessionId, - resume: true, - cwd: data.options?.cwd, - }, - writer - ); - return; - } - - if (messageType === 'abort-session') { - const provider = readProvider(data.provider); - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; - let success = false; - - if (provider === 'cursor') { - success = dependencies.abortCursorSession(sessionId); - } else if (provider === 'codex') { - success = dependencies.abortCodexSession(sessionId); - } else if (provider === 'gemini') { - success = dependencies.abortGeminiSession(sessionId); - } else if (provider === 'opencode') { - success = dependencies.abortOpenCodeSession(sessionId); - } else { - success = await dependencies.abortClaudeSDKSession(sessionId); - } - - writer.send( - createNormalizedMessage({ - kind: 'complete', - exitCode: success ? 0 : 1, - aborted: true, - success, - sessionId, - provider, - }) - ); - return; - } - - if (messageType === 'claude-permission-response') { - if (typeof data.requestId === 'string' && data.requestId.length > 0) { - dependencies.resolveToolApproval(data.requestId, { - allow: Boolean(data.allow), - updatedInput: data.updatedInput, - message: typeof data.message === 'string' ? data.message : undefined, - rememberEntry: data.rememberEntry, - }); - } - return; - } - - if (messageType === 'cursor-abort') { - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; - const success = dependencies.abortCursorSession(sessionId); - writer.send( - createNormalizedMessage({ - kind: 'complete', - exitCode: success ? 0 : 1, - aborted: true, - success, - sessionId, - provider: 'cursor', - }) - ); - return; - } - - if (messageType === 'check-session-status') { - const provider = readProvider(data.provider); - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; - let isActive = false; - - if (provider === 'cursor') { - isActive = dependencies.isCursorSessionActive(sessionId); - } else if (provider === 'codex') { - isActive = dependencies.isCodexSessionActive(sessionId); - } else if (provider === 'gemini') { - isActive = dependencies.isGeminiSessionActive(sessionId); - } else if (provider === 'opencode') { - isActive = dependencies.isOpenCodeSessionActive(sessionId); - } else { - isActive = dependencies.isClaudeSDKSessionActive(sessionId); - if (isActive) { - dependencies.reconnectSessionWriter(sessionId, ws); - } - } - - writer.send({ - type: 'session-status', - sessionId, - provider, - isProcessing: isActive, - }); - return; - } - - if (messageType === 'get-pending-permissions') { - const sessionId = typeof data.sessionId === 'string' ? data.sessionId : ''; - if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) { - const pending = dependencies.getPendingApprovalsForSession(sessionId); - writer.send({ - type: 'pending-permissions-response', - sessionId, - data: pending, - }); - } - return; - } - - if (messageType === 'get-active-sessions') { - writer.send({ - type: 'active-sessions', - sessions: { - claude: dependencies.getActiveClaudeSDKSessions(), - cursor: dependencies.getActiveCursorSessions(), - codex: dependencies.getActiveCodexSessions(), - gemini: dependencies.getActiveGeminiSessions(), - opencode: dependencies.getActiveOpenCodeSessions(), - }, - }); + switch (messageType) { + case 'chat.send': + await handleChatSend(ws, userId, data, dependencies); + return; + case 'chat.abort': + await handleChatAbort(ws, data, dependencies); + return; + case 'chat.subscribe': + handleChatSubscribe(ws, data, dependencies); + return; + case 'chat.permission-response': + handlePermissionResponse(data, dependencies); + return; + default: + sendProtocolError(ws, 'UNKNOWN_MESSAGE_TYPE', `Unknown message type "${messageType}".`); + return; } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error('[ERROR] Chat WebSocket error:', message); - writer.send({ - type: 'error', - error: message, - }); + sendProtocolError(ws, 'INTERNAL_ERROR', message); } }); diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index a29959be..d41b781f 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -35,7 +35,10 @@ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000; const SHELL_URL_PARSE_BUFFER_LIMIT = 32768; type ShellWebSocketDependencies = { - getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined; + resolveProviderSessionId: ( + sessionId: string, + provider: string, + ) => string | null | undefined; stripAnsiSequences: (content: string) => string; normalizeDetectedUrl: (url: string) => string | null; extractUrlsFromText: (content: string) => string[]; @@ -76,6 +79,36 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null { return payload as ShellIncomingMessage; } +const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/; + +function resolveResumeSessionId( + message: ShellIncomingMessage, + dependencies: ShellWebSocketDependencies +): string { + const hasSession = readBoolean(message.hasSession); + const sessionId = readString(message.sessionId); + const provider = readString(message.provider, 'claude'); + + if (!hasSession || !sessionId) { + return ''; + } + + let resumeSessionId: string | null | undefined; + try { + resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider); + } catch (error) { + console.error('Failed to resolve provider session ID:', error); + resumeSessionId = undefined; + } + + const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId; + if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) { + return ''; + } + + return resolvedSessionId; +} + /** * Resolves provider command line for plain shell and agent-backed shell modes. */ @@ -84,10 +117,9 @@ function buildShellCommand( dependencies: ShellWebSocketDependencies ): string { const hasSession = readBoolean(message.hasSession); - const sessionId = readString(message.sessionId); const initialCommand = readString(message.initialCommand); const provider = readString(message.provider, 'claude'); - const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/; + const resumeSessionId = resolveResumeSessionId(message, dependencies); const isPlainShell = readBoolean(message.isPlainShell) || (!!initialCommand && !hasSession) || @@ -98,58 +130,43 @@ function buildShellCommand( } if (provider === 'cursor') { - if (hasSession && sessionId) { - return `cursor-agent --resume="${sessionId}"`; + if (resumeSessionId) { + return `cursor-agent --resume="${resumeSessionId}"`; } return 'cursor-agent'; } if (provider === 'codex') { - if (hasSession && sessionId) { + if (resumeSessionId) { if (os.platform() === 'win32') { - return `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; + return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; } - return `codex resume "${sessionId}" || codex`; + return `codex resume "${resumeSessionId}" || codex`; } return 'codex'; } if (provider === 'gemini') { const command = initialCommand || 'gemini'; - let resumeId = sessionId; - if (hasSession && sessionId) { - try { - const existingSession = dependencies.getSessionById(sessionId); - if (existingSession && existingSession.cliSessionId) { - resumeId = existingSession.cliSessionId; - if (!safeSessionIdPattern.test(resumeId)) { - resumeId = ''; - } - } - } catch (error) { - console.error('Failed to get Gemini CLI session ID:', error); - } - } - - if (hasSession && resumeId) { - return `${command} --resume "${resumeId}"`; + if (resumeSessionId) { + return `${command} --resume "${resumeSessionId}"`; } return command; } if (provider === 'opencode') { - if (hasSession && sessionId) { - return `opencode --session "${sessionId}"`; + if (resumeSessionId) { + return `opencode --session "${resumeSessionId}"`; } return initialCommand || 'opencode'; } const command = initialCommand || 'claude'; - if (hasSession && sessionId) { + if (resumeSessionId) { if (os.platform() === 'win32') { - return `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; + return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; } - return `claude --resume "${sessionId}" || claude`; + return `claude --resume "${resumeSessionId}" || claude`; } return command; } @@ -261,6 +278,7 @@ export function handleShellConnection( } const shellCommand = buildShellCommand(data, dependencies); + const resumeSessionId = resolveResumeSessionId(data, dependencies); const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; @@ -406,8 +424,8 @@ export function handleShellConnection( : provider === 'opencode' ? 'OpenCode' : 'Claude'; - welcomeMsg = hasSession - ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` + welcomeMsg = hasSession && resumeSessionId + ? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n` : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; } diff --git a/server/modules/websocket/tests/chat-run-registry.test.ts b/server/modules/websocket/tests/chat-run-registry.test.ts new file mode 100644 index 00000000..cc6250c0 --- /dev/null +++ b/server/modules/websocket/tests/chat-run-registry.test.ts @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; +import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js'; + +/** + * Minimal stand-in for a websocket connection: collects every JSON frame the + * gateway writer forwards so assertions can inspect the outbound protocol. + */ +class FakeConnection { + readyState = 1; // WS_OPEN_STATE + frames: Array> = []; + + send(data: string): void { + this.frames.push(JSON.parse(data) as Record); + } +} + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'chat-run-registry-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + connectedClients.clear(); + chatRunRegistry.clearAll(); + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('live events are remapped to the app session id and sequenced', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-1', 'claude', '/workspace/demo'); + const connection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-1', + provider: 'claude', + providerSessionId: null, + connection, + userId: 'user-1', + }); + assert.ok(run); + + run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'provider-id-9', content: 'hello' }); + run.writer.send({ kind: 'text', provider: 'claude', sessionId: 'provider-id-9', content: 'hello world' }); + + assert.equal(connection.frames.length, 2); + assert.equal(connection.frames[0]?.sessionId, 'app-run-1'); + assert.equal(connection.frames[0]?.seq, 1); + assert.equal(connection.frames[1]?.sessionId, 'app-run-1'); + assert.equal(connection.frames[1]?.seq, 2); + }); +}); + +test('session_created is swallowed and persisted as the provider-id mapping', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo'); + const connection = new FakeConnection(); + connectedClients.add(connection as never); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-2', + provider: 'cursor', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(run); + + run.writer.send({ + kind: 'session_created', + provider: 'cursor', + sessionId: 'cursor-native-7', + newSessionId: 'cursor-native-7', + }); + + // The provider-native event itself is never forwarded... + const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted'); + assert.equal(sessionUpserts.length, 1); + assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2'); + assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7'); + // ...but the canonical mapping is recorded and persisted in the database. + assert.equal(run.providerSessionId, 'cursor-native-7'); + assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7'); + }); +}); + +test('complete marks the run finished and duplicate completes are dropped', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-3', 'codex', '/workspace/demo'); + const connection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-3', + provider: 'codex', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(run); + + run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 0 }); + // Late duplicate from a killed runtime's exit handler. + run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 1 }); + + const completes = connection.frames.filter((frame) => frame.kind === 'complete'); + assert.equal(completes.length, 1); + assert.equal(completes[0]?.actualSessionId, 'app-run-3'); + assert.equal(chatRunRegistry.isProcessing('app-run-3'), false); + + // completeRun is also a no-op once the run already completed. + chatRunRegistry.completeRun('app-run-3', { exitCode: 1 }); + assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1); + }); +}); + +test('listRunningRuns returns only currently running app sessions', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo'); + sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo'); + const connection = new FakeConnection(); + + const completedRun = chatRunRegistry.startRun({ + appSessionId: 'app-run-7', + provider: 'claude', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(completedRun); + + const runningRun = chatRunRegistry.startRun({ + appSessionId: 'app-run-8', + provider: 'codex', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(runningRun); + + chatRunRegistry.completeRun('app-run-7', { exitCode: 0 }); + + const runningSessions = chatRunRegistry.listRunningRuns(); + assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']); + assert.equal(runningSessions[0]?.provider, 'codex'); + }); +}); + +test('replayEvents returns only events after the requested seq', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo'); + const connection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-4', + provider: 'claude', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(run); + + run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'a' }); + run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'b' }); + run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'c' }); + + const replayed = chatRunRegistry.replayEvents('app-run-4', 1); + assert.deepEqual(replayed.map((event) => event.content), ['b', 'c']); + assert.deepEqual(replayed.map((event) => event.seq), [2, 3]); + }); +}); + +test('attachConnection reroutes the live stream to a new socket', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-5', 'gemini', '/workspace/demo'); + const firstConnection = new FakeConnection(); + const run = chatRunRegistry.startRun({ + appSessionId: 'app-run-5', + provider: 'gemini', + providerSessionId: null, + connection: firstConnection, + userId: null, + }); + assert.ok(run); + + run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'before' }); + + const secondConnection = new FakeConnection(); + assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true); + run.writer.send({ kind: 'stream_delta', provider: 'gemini', sessionId: 'g', content: 'after' }); + + assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']); + assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']); + }); +}); + +test('startRun rejects a second concurrent run for the same session', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-run-6', 'opencode', '/workspace/demo'); + const connection = new FakeConnection(); + const first = chatRunRegistry.startRun({ + appSessionId: 'app-run-6', + provider: 'opencode', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(first); + + const second = chatRunRegistry.startRun({ + appSessionId: 'app-run-6', + provider: 'opencode', + providerSessionId: null, + connection, + userId: null, + }); + assert.equal(second, null); + + // After the run finishes a new one is allowed again. + chatRunRegistry.completeRun('app-run-6', { exitCode: 0 }); + const third = chatRunRegistry.startRun({ + appSessionId: 'app-run-6', + provider: 'opencode', + providerSessionId: null, + connection, + userId: null, + }); + assert.ok(third); + }); +}); diff --git a/server/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..e8446329 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) { @@ -191,6 +194,10 @@ async function spawnOpenCode(command, options = {}, ws) { void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => { const args = ['run', '--format', 'json']; + // OpenCode's `run` command owns workspace selection through `--dir`. + // Relying on the child-process cwd alone is not enough on Linux, where + // the CLI can still resolve the session under the server install dir. + args.push('--dir', workingDir); if (sessionId) { args.push('--session', sessionId); } @@ -256,13 +263,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 +308,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 +325,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/opencode-cli.test.js b/server/opencode-cli.test.js index 082451c3..acac202c 100644 --- a/server/opencode-cli.test.js +++ b/server/opencode-cli.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -12,6 +12,11 @@ const findEnvKey = (name) => async function createFakeOpenCodeExecutable(binDir) { const scriptPath = path.join(binDir, 'opencode.js'); await writeFile(scriptPath, ` +const capturePath = process.env.OPENCODE_ARGS_CAPTURE; +if (capturePath) { + require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2))); +} + const events = [ { type: 'text', sessionID: 'open-live-1', text: 'assistant response' }, { type: 'step_finish', sessionID: 'open-live-1' }, @@ -35,10 +40,12 @@ for (const event of events) { test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-')); + const argsCapturePath = path.join(tempRoot, 'opencode-args.json'); const pathKey = findEnvKey('PATH'); const pathExtKey = findEnvKey('PATHEXT'); const previousPath = process.env[pathKey]; const previousPathExt = process.env[pathExtKey]; + const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE; const messages = []; const writer = { userId: null, @@ -54,6 +61,7 @@ test('spawnOpenCode emits session_created before normalized live messages for ne try { await createFakeOpenCodeExecutable(tempRoot); process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`; + process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath; if (process.platform === 'win32') { process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD') ? previousPathExt @@ -77,6 +85,11 @@ test('spawnOpenCode emits session_created before normalized live messages for ne assert.equal(streamEnd?.sessionId, 'open-live-1'); assert.equal(complete?.sessionId, 'open-live-1'); assert.equal(messages.some((message) => message.kind === 'error'), false); + + const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8')); + assert.ok(Array.isArray(launchedArgs)); + assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']); + assert.equal(launchedArgs[4], tempRoot); } finally { if (previousPath === undefined) { delete process.env[pathKey]; @@ -90,6 +103,12 @@ test('spawnOpenCode emits session_created before normalized live messages for ne process.env[pathExtKey] = previousPathExt; } + if (previousArgsCapture === undefined) { + delete process.env.OPENCODE_ARGS_CAPTURE; + } else { + process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture; + } + await rm(tempRoot, { recursive: true, force: true }); } }); diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js index 43a7d058..f25fffb3 100644 --- a/server/services/notification-orchestrator.js +++ b/server/services/notification-orchestrator.js @@ -98,6 +98,44 @@ function normalizeSessionName(sessionName) { return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; } +function rowMatchesProvider(row, provider) { + return row && (!provider || row.provider === provider); +} + +function resolveSessionRow(sessionId, provider) { + if (!sessionId) { + return null; + } + + const appSessionRow = sessionsDb.getSessionById(sessionId); + if (rowMatchesProvider(appSessionRow, provider)) { + return appSessionRow; + } + + const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId); + if (rowMatchesProvider(providerSessionRow, provider)) { + return providerSessionRow; + } + + return null; +} + +function normalizeNotificationSession(event) { + if (!event?.sessionId || !event.provider || event.provider === 'system') { + return event; + } + + const row = resolveSessionRow(event.sessionId, event.provider); + if (!row || row.session_id === event.sessionId) { + return event; + } + + return { + ...event, + sessionId: row.session_id + }; +} + function resolveSessionName(event) { const explicitSessionName = normalizeSessionName(event.meta?.sessionName); if (explicitSessionName) { @@ -112,28 +150,29 @@ function resolveSessionName(event) { } function buildPushBody(event) { + const normalizedEvent = normalizeNotificationSession(event); const CODE_MAP = { - 'permission.required': event.meta?.toolName - ? `Action Required: Tool "${event.meta.toolName}" needs approval` + 'permission.required': normalizedEvent.meta?.toolName + ? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval` : 'Action Required: A tool needs your approval', - 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped', - 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error', - 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification', + 'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped', + 'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error', + 'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification', 'push.enabled': 'Push notifications are now enabled!' }; - const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant'; - const sessionName = resolveSessionName(event); - const message = CODE_MAP[event.code] || 'You have a new notification'; + const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant'; + const sessionName = resolveSessionName(normalizedEvent); + const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification'; return { title: sessionName || 'CloudCLI', body: `${providerLabel}: ${message}`, data: { - sessionId: event.sessionId || null, - code: event.code, - provider: event.provider || null, + sessionId: normalizedEvent.sessionId || null, + code: normalizedEvent.code, + provider: normalizedEvent.provider || null, sessionName, - tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}` + tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}` } }; } @@ -175,15 +214,16 @@ function notifyUserIfEnabled({ userId, event }) { return; } + const normalizedEvent = normalizeNotificationSession(event); const preferences = notificationPreferencesDb.getPreferences(userId); - if (!shouldSendPush(preferences, event)) { + if (!shouldSendPush(preferences, normalizedEvent)) { return; } - if (isDuplicate(event)) { + if (isDuplicate(normalizedEvent)) { return; } - sendWebPush(userId, event).catch((err) => { + sendWebPush(userId, normalizedEvent).catch((err) => { console.error('Web push send error:', err); }); } diff --git a/server/services/notification-orchestrator.test.js b/server/services/notification-orchestrator.test.js new file mode 100644 index 00000000..d470765f --- /dev/null +++ b/server/services/notification-orchestrator.test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import webPush from 'web-push'; + +import { + closeConnection, + initializeDatabase, + notificationPreferencesDb, + pushSubscriptionsDb, + sessionsDb, + userDb, +} from '../modules/database/index.js'; + +import { notifyRunStopped } from './notification-orchestrator.js'; + +async function withIsolatedDatabase(runTest) { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(tmpdir(), 'notification-orchestrator-')); + const databasePath = path.join(tempDirectory, 'auth.db'); + + closeConnection(); + process.env.DATABASE_PATH = databasePath; + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('push payload uses the app session id when notified with a provider session id', async () => { + const originalSendNotification = webPush.sendNotification; + const sentPayloads = []; + + webPush.sendNotification = async (_subscription, payload) => { + sentPayloads.push(JSON.parse(payload)); + return {}; + }; + + try { + await withIsolatedDatabase(async () => { + const user = userDb.createUser('notify-user', 'hash'); + const userId = Number(user.id); + + notificationPreferencesDb.updatePreferences(userId, { + channels: { webPush: true }, + events: { actionRequired: true, stop: true, error: true }, + }); + pushSubscriptionsDb.saveSubscription(userId, 'https://example.test/push', 'p256dh', 'auth'); + sessionsDb.createAppSession('app-session-1', 'claude', '/workspace/demo'); + sessionsDb.assignProviderSessionId('app-session-1', 'claude-native-1'); + + notifyRunStopped({ + userId, + provider: 'claude', + sessionId: 'claude-native-1', + stopReason: 'completed', + }); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(sentPayloads.length, 1); + assert.equal(sentPayloads[0]?.data?.sessionId, 'app-session-1'); + assert.match(sentPayloads[0]?.data?.tag, /app-session-1/); + }); + } finally { + webPush.sendNotification = originalSendNotification; + } +}); diff --git a/server/shared/tests/slice-tail-page.test.ts b/server/shared/tests/slice-tail-page.test.ts new file mode 100644 index 00000000..e4d17db2 --- /dev/null +++ b/server/shared/tests/slice-tail-page.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { sliceTailPage } from '@/shared/utils.js'; + +const ITEMS = ['a', 'b', 'c', 'd', 'e']; + +test('offset 0 returns the most recent page', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 2, 0); + assert.deepEqual(page, ['d', 'e']); + assert.equal(hasMore, true); +}); + +test('increasing offsets walk backwards in time', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 2, 2); + assert.deepEqual(page, ['b', 'c']); + assert.equal(hasMore, true); +}); + +test('the oldest page reports hasMore false', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 2, 4); + assert.deepEqual(page, ['a']); + assert.equal(hasMore, false); +}); + +test('null limit returns everything', () => { + const { page, hasMore } = sliceTailPage(ITEMS, null, 0); + assert.deepEqual(page, ITEMS); + assert.equal(hasMore, false); +}); + +test('offsets past the start return an empty page', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 3, 10); + assert.deepEqual(page, []); + assert.equal(hasMore, false); +}); + +test('zero limit returns an empty page but keeps hasMore accurate', () => { + const { page, hasMore } = sliceTailPage(ITEMS, 0, 0); + assert.deepEqual(page, []); + assert.equal(hasMore, true); +}); diff --git a/server/shared/types.ts b/server/shared/types.ts index de8b16c0..91c477a6 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -175,6 +175,30 @@ export type MessageKind = | 'interactive_prompt' | 'task_notification'; +/** + * Event kinds added by the chat gateway layer on top of provider message kinds. + * + * These are app-level realtime events (subscription acks, sidebar deltas, + * project loading progress, protocol failures) that are not produced by any + * provider adapter. Together with `MessageKind` they form the complete set of + * `kind` values a websocket client can receive, so the frontend only ever + * needs one kind-based switch. + */ +export type GatewayEventKind = + | 'chat_subscribed' + | 'session_upserted' + | 'loading_progress' + | 'protocol_error'; + +/** + * Complete set of `kind` values emitted to websocket clients. + * + * Every server-to-client websocket frame carries a `kind` from this union. + * Provider runtimes emit `MessageKind` values; gateway services emit + * `GatewayEventKind` values. + */ +export type ServerEventKind = MessageKind | GatewayEventKind; + /** * Provider-neutral message envelope used in REST responses and realtime channels. * @@ -187,6 +211,13 @@ export type NormalizedMessage = { timestamp: string; provider: LLMProvider; kind: MessageKind; + /** + * Monotonic per-run sequence number assigned by the chat run registry when a + * live event is forwarded to the websocket. History messages loaded over + * REST do not carry it. Clients use it with `chat.subscribe` to replay only + * the live events they missed across websocket reconnects. + */ + seq?: number; role?: 'user' | 'assistant'; content?: string; /** @@ -237,11 +268,18 @@ export type NormalizedMessage = { * * Consumers should pass provider-specific lookup hints (`projectPath`) only * when the selected provider requires them. + * + * `providerSessionId` is the provider-native session id from the sessions + * index (transcript file name / provider database key). Provider adapters + * must use it — never the app-facing session id they were called with — when + * matching transcript rows on disk, because app-created sessions use an + * app-allocated id that the provider has never seen. */ export type FetchHistoryOptions = { projectPath?: string; limit?: number | null; offset?: number; + providerSessionId?: string; }; /** diff --git a/server/shared/utils.ts b/server/shared/utils.ts index 821489c5..65fd4c22 100644 --- a/server/shared/utils.ts +++ b/server/shared/utils.ts @@ -346,6 +346,84 @@ 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, + }); +} + +// --------------------------- +//----------------- CONVERSATION HISTORY PAGINATION UTILITIES ------------ +/** + * Slices one page from the END of a chronologically ordered message list. + * + * This is the single pagination contract for conversation history across all + * providers: `offset = 0` returns the most recent `limit` items, increasing + * offsets walk backwards in time (for "scroll up to load older" UIs), and a + * `null` limit returns everything. Items must already be sorted oldest-first; + * the returned page preserves that order. + * + * Every provider history reader must use this helper instead of slicing + * manually so `offset`/`limit` query params behave identically regardless of + * which provider produced the session. + */ +export function sliceTailPage( + items: T[], + limit: number | null, + offset: number, +): { page: T[]; hasMore: boolean } { + const total = items.length; + const normalizedOffset = Math.max(0, offset); + + if (limit === null) { + // A null limit returns the full list; offset still trims newest entries + // so "everything before the page I already have" stays expressible. + const end = Math.max(0, total - normalizedOffset); + return { + page: items.slice(0, end), + hasMore: false, + }; + } + + const end = Math.max(0, total - normalizedOffset); + const start = Math.max(0, end - Math.max(0, limit)); + return { + page: items.slice(start, end), + hasMore: start > 0, + }; +} + // --------------------------- //----------------- MCP CONFIG PARSING UTILITIES ------------ /** diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 1ba41b95..ade11053 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -10,6 +10,33 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; +import { api } from '../../utils/api'; + +type RunningSessionApiItem = { + sessionId?: unknown; + startedAt?: unknown; + statusText?: unknown; + canInterrupt?: unknown; +}; + +type RunningSessionsApiPayload = { + data?: { + sessions?: RunningSessionApiItem[]; + }; +}; + +const parseStartedAt = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; export default function AppContent() { return ( @@ -24,16 +51,13 @@ function AppContentInner() { const { sessionId } = useParams<{ sessionId?: string }>(); const { t } = useTranslation('common'); const { isMobile } = useDeviceSettings({ trackPWA: false }); - const { ws, sendMessage, latestMessage, isConnected } = useWebSocket(); - const wasConnectedRef = useRef(false); + const { ws, sendMessage, subscribe } = useWebSocket(); const { - activeSessions, processingSessions, - markSessionAsActive, - markSessionAsInactive, - markSessionAsProcessing, - markSessionAsNotProcessing, + markSessionProcessing, + markSessionIdle, + syncProcessingSessions, } = useSessionProtection(); const { @@ -50,16 +74,64 @@ function AppContentInner() { setShowSettings, openSettings, refreshProjectsSilently, + registerOptimisticSession, sidebarSharedProps, handleNewSession, } = useProjectsState({ sessionId, navigate, - latestMessage, + subscribe, isMobile, - activeSessions, + activeSessions: processingSessions, }); + const refreshRunningSessions = useCallback(async () => { + try { + const response = await api.runningSessions(); + if (!response.ok) { + return; + } + + const payload = (await response.json()) as RunningSessionsApiPayload; + const sessions = Array.isArray(payload.data?.sessions) ? payload.data.sessions : []; + + syncProcessingSessions( + sessions + .map((session) => { + if (typeof session.sessionId !== 'string' || !session.sessionId) { + return null; + } + + return { + sessionId: session.sessionId, + startedAt: parseStartedAt(session.startedAt), + statusText: typeof session.statusText === 'string' ? session.statusText : undefined, + canInterrupt: typeof session.canInterrupt === 'boolean' ? session.canInterrupt : undefined, + }; + }) + .filter((session): session is NonNullable => Boolean(session)), + ); + } catch (error) { + console.error('[AppContent] Failed to sync running sessions:', error); + } + }, [syncProcessingSessions]); + + useEffect(() => { + void refreshRunningSessions(); + }, [refreshRunningSessions]); + + useEffect(() => { + if (processingSessions.size === 0) { + return; + } + + const interval = window.setInterval(() => { + void refreshRunningSessions(); + }, 5000); + + return () => window.clearInterval(interval); + }, [processingSessions.size, refreshRunningSessions]); + usePaletteOpsRegister({ openSettings, refreshProjects: refreshProjectsSilently, @@ -99,23 +171,9 @@ function AppContentInner() { }; }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]); - // Permission recovery: query pending permissions on WebSocket reconnect or session change - useEffect(() => { - const isReconnect = isConnected && !wasConnectedRef.current; - - if (isReconnect) { - wasConnectedRef.current = true; - } else if (!isConnected) { - wasConnectedRef.current = false; - } - - if (isConnected && selectedSession?.id) { - sendMessage({ - type: 'get-pending-permissions', - sessionId: selectedSession.id - }); - } - }, [isConnected, selectedSession?.id, sendMessage]); + // Pending tool permissions are recovered through the `chat.subscribe` flow: + // the `chat_subscribed` ack carries them on session open and on reconnect, + // so no separate permission-recovery message is needed here. // Adjust the app container to stay above the virtual keyboard on iOS Safari. // On Chrome for Android the layout viewport already shrinks when the keyboard opens, @@ -180,19 +238,19 @@ function AppContentInner() { setActiveTab={setActiveTab} ws={ws} sendMessage={sendMessage} - latestMessage={latestMessage} isMobile={isMobile} 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) }) } + onSessionEstablished={(targetSessionId, context) => + registerOptimisticSession({ sessionId: targetSessionId, ...context }) + } onShowSettings={() => setShowSettings(true)} externalMessageUpdate={externalMessageUpdate} newSessionTrigger={newSessionTrigger} diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index dca2b2f8..c1f86f2d 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -12,12 +12,14 @@ import type { import { useDropzone } from 'react-dropzone'; import { authenticatedFetch } from '../../../utils/api'; +import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection'; import { grantClaudeToolPermission } from '../utils/chatPermissions'; import { safeLocalStorage } from '../utils/chatStorage'; import type { ChatMessage, PendingPermissionRequest, PermissionMode, + SessionEstablishedContext, } from '../types/types'; import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app'; import { escapeRegExp } from '../utils/chatFormatting'; @@ -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,20 @@ interface UseChatComposerStateArgs { tokenBudget: Record | null; sendMessage: (message: unknown) => void; sendByCtrlEnter?: boolean; - onSessionActive?: (sessionId?: string | null) => void; - onSessionProcessing?: (sessionId?: string | null) => void; + onSessionProcessing?: MarkSessionProcessing; + /** + * Invoked with the freshly allocated session id when the user sends the + * first message of a brand-new conversation. The backend allocates the id + * via POST /api/providers/sessions BEFORE the websocket send, so the id is + * stable for the conversation's whole lifetime — the consumer navigates to + * /session/:id and records it as the current session. + */ + onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; 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 +178,13 @@ export function useChatComposerState({ tokenBudget, sendMessage, sendByCtrlEnter, - onSessionActive, onSessionProcessing, + onSessionEstablished, onInputFocusChange, onFileOpen, onShowSettings, - pendingViewSessionRef, scrollToBottom, addMessage, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setIsUserScrolledUp, setPendingPermissionRequests, }: UseChatComposerStateArgs) { @@ -609,8 +606,54 @@ export function useChatComposerState({ } } - const effectiveSessionId = - currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); + const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; + const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); + + // The conversation always has a stable backend-allocated session id + // BEFORE the first websocket send: brand-new chats allocate one here + // via the session gateway. There is no client-visible session-id + // handoff later — this id stays valid for the conversation's lifetime. + let targetSessionId = selectedSession?.id || currentSessionId || null; + if (!targetSessionId) { + try { + const response = await authenticatedFetch('/api/providers/sessions', { + method: 'POST', + body: JSON.stringify({ + provider, + projectPath: resolvedProjectPath, + }), + }); + if (!response.ok) { + throw new Error(`Failed to create session (${response.status})`); + } + const body = await response.json(); + targetSessionId = body?.data?.sessionId || null; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('Session creation failed:', error); + addMessage({ + type: 'error', + content: `Failed to start a new session: ${message}`, + timestamp: new Date(), + }); + return; + } + + if (!targetSessionId) { + addMessage({ + type: 'error', + content: 'Failed to start a new session: no session id returned.', + timestamp: new Date(), + }); + return; + } + + onSessionEstablished?.(targetSessionId, { + provider, + project: selectedProject, + summary: sessionSummary, + }); + } const userMessage: ChatMessage = { type: 'user', @@ -620,27 +663,17 @@ 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). The id is always + // concrete at this point — no pending placeholder exists anymore. + onSessionProcessing?.(targetSessionId, { + 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 = @@ -669,87 +702,35 @@ export function useChatComposerState({ }; const toolsSettings = getToolsSettings(); - const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; - const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput); + const model = + provider === 'cursor' + ? cursorModel + : provider === 'codex' + ? codexModel + : provider === 'gemini' + ? geminiModel + : provider === 'opencode' + ? opencodeModel + : claudeModel; - if (provider === 'cursor') { - sendMessage({ - type: 'cursor-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: resolvedProjectPath, - projectPath: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - model: cursorModel, - skipPermissions: toolsSettings?.skipPermissions || false, - sessionSummary, - toolsSettings, - }, - }); - } else if (provider === 'codex') { - sendMessage({ - type: 'codex-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: resolvedProjectPath, - projectPath: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - model: codexModel, - sessionSummary, - permissionMode: permissionMode === 'plan' ? 'default' : permissionMode, - }, - }); - } else if (provider === 'gemini') { - sendMessage({ - type: 'gemini-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: resolvedProjectPath, - projectPath: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - model: geminiModel, - sessionSummary, - permissionMode, - toolsSettings, - }, - }); - } else if (provider === 'opencode') { - sendMessage({ - type: 'opencode-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: resolvedProjectPath, - projectPath: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - model: opencodeModel, - sessionSummary, - }, - }); - } else { - sendMessage({ - type: 'claude-command', - command: messageContent, - options: { - projectPath: resolvedProjectPath, - cwd: resolvedProjectPath, - sessionId: effectiveSessionId, - resume: Boolean(effectiveSessionId), - toolsSettings, - permissionMode, - model: claudeModel, - sessionSummary, - images: uploadedImages, - }, - }); - } + // One message shape for every provider. The backend resolves the + // provider, project path, and provider-native resume id from the + // session row; `options` only carries composer-level preferences. + sendMessage({ + type: 'chat.send', + sessionId: targetSessionId, + content: messageContent, + options: { + model, + // Codex has no plan mode; downgrade rather than sending an + // unsupported value to its runtime. + permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode, + toolsSettings, + skipPermissions: toolsSettings?.skipPermissions || false, + sessionSummary, + images: uploadedImages, + }, + }); setInput(''); inputValueRef.current = ''; @@ -776,19 +757,15 @@ export function useChatComposerState({ geminiModel, opencodeModel, isLoading, - onSessionActive, onSessionProcessing, - pendingViewSessionRef, + onSessionEstablished, permissionMode, provider, resetCommandMenuState, scrollToBottom, selectedProject, sendMessage, - setCanAbortSession, addMessage, - setClaudeStatus, - setIsLoading, setIsUserScrolledUp, slashCommands, ], @@ -944,29 +921,19 @@ export function useChatComposerState({ return; } - const cursorSessionId = - typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null; - - const candidateSessionIds = [ - currentSessionId, - provider === 'cursor' ? cursorSessionId : null, - selectedSession?.id || null, - ]; - - const targetSessionId = - candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null; - + const targetSessionId = selectedSession?.id || currentSessionId || null; if (!targetSessionId) { - console.warn('Abort requested but no concrete session ID is available yet.'); + console.warn('Abort requested but no session ID is available.'); return; } + // The backend resolves the provider from the session row, so no provider + // field is needed here. sendMessage({ - type: 'abort-session', + type: 'chat.abort', sessionId: targetSessionId, - provider, }); - }, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]); + }, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]); const handleGrantToolPermission = useCallback( (suggestion: { entry: string; toolName: string }) => { @@ -991,7 +958,7 @@ export function useChatComposerState({ validIds.forEach((requestId) => { sendMessage({ - type: 'claude-permission-response', + type: 'chat.permission-response', requestId, allow: Boolean(decision?.allow), updatedInput: decision?.updatedInput, @@ -1000,15 +967,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/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 33c54d4b..a2910b0d 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -17,17 +17,35 @@ const FALLBACK_DEFAULT_MODEL: Record = { opencode: 'anthropic/claude-sonnet-4-5', }; -const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => { - if (provider === 'codex') { - return ['default', 'acceptEdits', 'bypassPermissions']; - } - if (provider === 'claude') { - return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan']; - } - if (provider === 'opencode') { - return ['default']; - } - return ['default', 'acceptEdits', 'bypassPermissions', 'plan']; +/** + * Fallback permission-mode matrix used only until the backend capability + * matrix (`GET /api/providers/capabilities`) has loaded. The backend is the + * source of truth; this mirror exists so the composer renders sensibly on + * first paint and when the capabilities request fails. + */ +const FALLBACK_PERMISSION_MODES: Record = { + claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'], + cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + codex: ['default', 'acceptEdits', 'bypassPermissions'], + gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], + opencode: ['default'], +}; + +type ProviderCapabilities = { + provider: LLMProvider; + permissionModes: string[]; + defaultPermissionMode: string; + supportsImages: boolean; + supportsAbort: boolean; + supportsPermissionRequests: boolean; + supportsTokenUsage: boolean; +}; + +type ProviderCapabilitiesApiResponse = { + success?: boolean; + data?: { + providers?: ProviderCapabilities[]; + }; }; interface UseChatProviderStateArgs { @@ -76,6 +94,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode; }); + /** + * Backend-owned capability matrix keyed by provider. Drives the permission + * mode picker (and is the extension point for future per-provider UI + * differences) so the frontend stays free of hardcoded provider branching. + * Null until `/api/providers/capabilities` resolves; the static fallback + * map covers that window. + */ + const [providerCapabilities, setProviderCapabilities] = useState< + Partial> | null + >(null); + const [providerModelCatalog, setProviderModelCatalog] = useState< Partial> >({}); @@ -181,6 +210,41 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh void loadProviderModels(); }, [loadProviderModels]); + useEffect(() => { + let cancelled = false; + + const loadCapabilities = async () => { + try { + const response = await authenticatedFetch('/api/providers/capabilities'); + const body = (await response.json()) as ProviderCapabilitiesApiResponse; + if (cancelled || !body.success || !Array.isArray(body.data?.providers)) { + return; + } + + const byProvider: Partial> = {}; + for (const capabilities of body.data.providers) { + byProvider[capabilities.provider] = capabilities; + } + setProviderCapabilities(byProvider); + } catch (error) { + console.error('Error loading provider capabilities:', error); + } + }; + + void loadCapabilities(); + return () => { + cancelled = true; + }; + }, []); + + const getPermissionModesForProvider = useCallback((targetProvider: LLMProvider): PermissionMode[] => { + const capabilityModes = providerCapabilities?.[targetProvider]?.permissionModes; + if (capabilityModes && capabilityModes.length > 0) { + return capabilityModes as PermissionMode[]; + } + return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default']; + }, [providerCapabilities]); + const pickStoredOrCurrent = ( storageKey: string, current: string, @@ -269,7 +333,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null; const validModes = getPermissionModesForProvider(provider); setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default'); - }, [selectedSession?.id, provider]); + }, [selectedSession?.id, provider, getPermissionModesForProvider]); useEffect(() => { if (!selectedSession?.__provider || selectedSession.__provider === provider) { @@ -327,7 +391,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh if (selectedSession?.id) { localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode); } - }, [permissionMode, provider, selectedSession?.id]); + }, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]); const selectProviderModel = useCallback(async ( targetProvider: LLMProvider, diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index c2683933..be741c7e 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,73 +1,34 @@ -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; -import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; +import type { ServerEvent } from '../../../contexts/WebSocketContext'; import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification'; import { playChatCompletionSound } from '../../../utils/notificationSound'; -import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types'; +import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection'; +import type { PendingPermissionRequest } 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; - data?: any; - message?: any; - delta?: string; - sessionId?: string; - session_id?: string; - requestId?: string; - toolName?: string; - input?: unknown; - context?: unknown; - error?: string; - tool?: any; - toolId?: string; - result?: any; - exitCode?: number; - isProcessing?: boolean; - actualSessionId?: string; - event?: string; - status?: any; - isNewSession?: boolean; - resultText?: string; - isError?: boolean; - success?: boolean; - reason?: string; - provider?: string; - content?: string; - text?: string; - tokens?: number; - canInterrupt?: boolean; - tokenBudget?: unknown; - newSessionId?: string; - aborted?: boolean; - [key: string]: any; -}; - interface UseChatRealtimeHandlersArgs { - latestMessage: LatestChatMessage | null; + subscribe: (listener: (event: ServerEvent) => void) => () => void; provider: LLMProvider; selectedSession: ProjectSession | null; currentSessionId: string | null; - setCurrentSessionId: (sessionId: string | null) => void; - 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; - onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void; + /** + * Highest live `seq` observed per session. Essential for reconnect catch-up: + * `chat.subscribe` sends this value as `lastSeq` so the server replays only + * the events this client actually missed. Written here on every sequenced + * frame; read wherever a `chat.subscribe` is sent (session open, reconnect). + */ + lastSeqRef: MutableRefObject>; + /** When each session's `chat.subscribe` was last sent; guards stale idle acks. */ + statusCheckSentAtRef: MutableRefObject>; + onSessionProcessing?: MarkSessionProcessing; + onSessionIdle?: MarkSessionIdle; onWebSocketReconnect?: () => void; sessionStore: SessionStore; } @@ -76,324 +37,259 @@ interface UseChatRealtimeHandlersArgs { /* Hook */ /* ------------------------------------------------------------------ */ +/** + * Routes server events into the session store and processing-state map. + * + * This is intentionally a thin reducer over the unified `kind`-based + * protocol: every frame is keyed by the stable app session id, so there is + * no session-id handoff, no provider branching, and no navigation here. + * Sidebar events (`session_upserted`, `loading_progress`) are handled by + * `useProjectsState`, not in this hook. + */ export function useChatRealtimeHandlers({ - latestMessage, + subscribe, provider, selectedSession, currentSessionId, - setCurrentSessionId, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setTokenBudget, setPendingPermissionRequests, - pendingViewSessionRef, streamTimerRef, accumulatedStreamRef, - onSessionInactive, - onSessionActive, + lastSeqRef, + statusCheckSentAtRef, onSessionProcessing, - onSessionNotProcessing, - onNavigateToSession, + onSessionIdle, onWebSocketReconnect, sessionStore, }: UseChatRealtimeHandlersArgs) { - const paletteOps = usePaletteOps(); - const lastProcessedMessageRef = useRef(null); - useEffect(() => { - if (!latestMessage) return; - if (lastProcessedMessageRef.current === latestMessage) return; - lastProcessedMessageRef.current = latestMessage; + const handleEvent = (msg: ServerEvent) => { + if (!msg.kind) { + return; + } - const activeViewSessionId = - selectedSession?.id || currentSessionId || null; + const activeViewSessionId = selectedSession?.id || currentSessionId || null; + const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId; - /* ---------------------------------------------------------------- */ - /* Legacy messages (no `kind` field) — handle and return */ - /* ---------------------------------------------------------------- */ + // Record replay progress for every sequenced live event. + if (sid && typeof msg.seq === 'number') { + const known = lastSeqRef.current.get(sid) ?? 0; + if (msg.seq > known) { + lastSeqRef.current.set(sid, msg.seq); + } + } - const msg = latestMessage as any; - - if (!msg.kind) { - const messageType = String(msg.type || ''); - - switch (messageType) { - case 'websocket-reconnected': + switch (msg.kind) { + case 'websocket_reconnected': onWebSocketReconnect?.(); return; - case 'pending-permissions-response': { - const permSessionId = msg.sessionId; - const isCurrentPermSession = - permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id); - if (permSessionId && !isCurrentPermSession) return; - setPendingPermissionRequests(msg.data || []); - return; - } - - case 'session-status': { - const statusSessionId = msg.sessionId; - if (!statusSessionId) return; - - 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); - return; - } - - // Legacy isProcessing format from check-session-status - const isCurrentSession = - statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); + case 'chat_subscribed': { + // Ack for chat.subscribe: authoritative processing state plus any + // pending tool-permission prompts for the run. + if (!sid) return; if (msg.isProcessing) { - onSessionActive?.(statusSessionId); - onSessionProcessing?.(statusSessionId); - if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); } - return; + onSessionProcessing?.(sid); + } else { + // Idle ack: ignore it if a newer request started after the + // subscribe was sent — the ack describes the older state. + onSessionIdle?.(sid, { + ifStartedBefore: statusCheckSentAtRef.current.get(sid), + }); } - onSessionInactive?.(statusSessionId); - onSessionNotProcessing?.(statusSessionId); - if (isCurrentSession) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); + const isViewedSession = sid === activeViewSessionId; + if (isViewedSession && Array.isArray(msg.pendingPermissions)) { + setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]); } return; } + case 'protocol_error': { + console.error('[Chat] Protocol error:', msg.code, msg.error); + if (sid) { + // Surface the failure in the conversation and stop the spinner — + // the run never started (or was rejected), so no `complete` follows. + onSessionIdle?.(sid); + sessionStore.appendRealtime(sid, { + id: `protocol_error_${Date.now()}`, + sessionId: sid, + timestamp: new Date().toISOString(), + provider, + kind: 'error', + content: String(msg.error || 'Request failed'), + } as NormalizedMessage); + } + return; + } + + // Sidebar/global events — owned by useProjectsState. + case 'session_upserted': + case 'loading_progress': + return; + default: - // Unknown legacy message type — ignore - return; + break; } - } - /* ---------------------------------------------------------------- */ - /* NormalizedMessage handling (has `kind` field) */ - /* ---------------------------------------------------------------- */ + /* -------------------------------------------------------------- */ + /* Provider NormalizedMessage handling */ + /* -------------------------------------------------------------- */ - const sid = msg.sessionId || activeViewSessionId; - - // --- Streaming: buffer for performance --- - if (msg.kind === 'stream_delta') { - const text = msg.content || ''; - if (!text) return; - accumulatedStreamRef.current += text; - if (!streamTimerRef.current) { - streamTimerRef.current = window.setTimeout(() => { - streamTimerRef.current = null; - if (sid) { - sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); - } - }, 100); - } - // Also route to store for non-active sessions - if (sid && sid !== activeViewSessionId) { - sessionStore.appendRealtime(sid, msg as NormalizedMessage); - } - return; - } - - if (msg.kind === 'stream_end') { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - if (sid) { - if (accumulatedStreamRef.current) { - sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + // --- Streaming: buffer for performance --- + if (msg.kind === 'stream_delta') { + const text = (msg.content as string) || ''; + if (!text) return; + accumulatedStreamRef.current += text; + if (!streamTimerRef.current) { + streamTimerRef.current = window.setTimeout(() => { + streamTimerRef.current = null; + if (sid) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + } + }, 100); } - sessionStore.finalizeStreaming(sid); - } - accumulatedStreamRef.current = ''; - return; - } - - // --- All other messages: route to store --- - const shouldPersist = - msg.kind !== 'session_created' - && msg.kind !== 'complete' - && msg.kind !== 'status' - && msg.kind !== 'permission_request' - && msg.kind !== 'permission_cancelled'; - - if (sid && shouldPersist) { - sessionStore.appendRealtime(sid, msg as NormalizedMessage); - } - - // --- UI side effects for specific kinds --- - switch (msg.kind) { - case 'session_created': { - const newSessionId = msg.newSessionId; - if (!newSessionId) break; - - // We no longer synthesize client-side placeholder IDs. Until the provider - // announces `session_created`, the active id is expected to be null. - if (!currentSessionId) { - 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 })), - ); + // Also route to store for non-active sessions + if (sid && sid !== activeViewSessionId) { + sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage); } - pendingViewSessionRef.current = null; - onSessionActive?.(newSessionId); - onSessionProcessing?.(newSessionId); - setIsLoading(true); - setCanAbortSession(true); - setClaudeStatus({ - text: 'Processing', - tokens: 0, - can_interrupt: true, - }); - onNavigateToSession?.(newSessionId); - break; + return; } - case 'complete': { - // Flush any remaining streaming state + if (msg.kind === 'stream_end') { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } - if (sid && accumulatedStreamRef.current) { - sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + if (sid) { + if (accumulatedStreamRef.current) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + } sessionStore.finalizeStreaming(sid); } accumulatedStreamRef.current = ''; + return; + } - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - setPendingPermissionRequests([]); - onSessionInactive?.(sid); - onSessionNotProcessing?.(sid); - pendingViewSessionRef.current = null; + // --- All other messages: route to store --- + const shouldPersist = + msg.kind !== 'complete' + && msg.kind !== 'status' + && msg.kind !== 'permission_request' + && msg.kind !== 'permission_cancelled'; + + if (sid && shouldPersist) { + sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage); + } + + // --- UI side effects for specific kinds --- + switch (msg.kind) { + case 'complete': { + // Flush any remaining streaming state + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current); + streamTimerRef.current = null; + } + if (sid && accumulatedStreamRef.current) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + sessionStore.finalizeStreaming(sid); + } + accumulatedStreamRef.current = ''; + + // `complete` is the unified terminal event — every provider run ends + // with exactly one, regardless of success, failure, or abort. The + // indicator derives from the processing map, so deleting the entry + // hides it immediately and atomically. + onSessionIdle?.(sid); + setPendingPermissionRequests([]); + + if (msg.aborted) { + // Abort was requested — the complete event confirms it. No + // further UI action is needed beyond clearing the entry above. + break; + } + + // Celebrate only successful runs (failed runs end with success: false). + if (msg.success !== false) { + showCompletionTitleIndicator(); + void playChatCompletionSound(); + } + + // The session id is stable for the whole conversation (allocated + // before the first send), so the only follow-up is syncing the + // viewed conversation with the now-persisted transcript. + if (sid && sid === activeViewSessionId) { + void sessionStore.refreshFromServer(sid); + } - // Handle aborted case - if (msg.aborted) { - // Abort was requested — the complete event confirms it - // No special UI action needed beyond clearing loading state above - // The backend already sent any abort-related messages break; } - showCompletionTitleIndicator(); - void playChatCompletionSound(); + // 'error' is an informational message row, not a terminal event — + // providers emit it for mid-run stderr output too. Run teardown is + // always signalled by the unified 'complete' that follows. - const actualSessionId = - typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0 - ? msg.actualSessionId - : null; - const isVisibleSession = - Boolean( - sid - && sid === activeViewSessionId, - ); - - if (actualSessionId && sid && actualSessionId !== sid) { - sessionStore.replaceSessionId(sid, actualSessionId); - - if (isVisibleSession) { - setCurrentSessionId(actualSessionId); - } - - if (isVisibleSession) { - onNavigateToSession?.(actualSessionId, { replace: true }); - setTimeout(() => { void paletteOps.refreshProjects(); }, 500); - } - break; - } - - break; - } - - case 'error': { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - onSessionInactive?.(sid); - onSessionNotProcessing?.(sid); - pendingViewSessionRef.current = null; - break; - } - - case 'permission_request': { - if (!msg.requestId) break; - setPendingPermissionRequests((prev) => { - if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; - return [...prev, { - requestId: msg.requestId, - toolName: msg.toolName || 'UnknownTool', - input: msg.input, - context: msg.context, - sessionId: sid || null, - receivedAt: new Date(), - }]; - }); - setIsLoading(true); - setCanAbortSession(true); - setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true }); - break; - } - - case 'permission_cancelled': { - if (msg.requestId) { - setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId)); - } - break; - } - - case 'status': { - if (msg.text === 'token_budget' && msg.tokenBudget) { - setTokenBudget(msg.tokenBudget as Record); - } else if (msg.text) { - setClaudeStatus({ - text: msg.text, - tokens: msg.tokens || 0, - can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true, + case 'permission_request': { + if (!msg.requestId) break; + setPendingPermissionRequests((prev) => { + if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; + return [...prev, { + requestId: msg.requestId as string, + toolName: (msg.toolName as string) || 'UnknownTool', + input: msg.input, + context: msg.context, + sessionId: sid || null, + receivedAt: new Date(), + }]; }); - setIsLoading(true); - setCanAbortSession(msg.canInterrupt !== false); + if (sid) { + onSessionProcessing?.(sid); + } + break; } - break; - } - // text, tool_use, tool_result, thinking, interactive_prompt, task_notification - // → already routed to store above, no UI side effects needed - default: - break; - } + case 'permission_cancelled': { + if (msg.requestId) { + setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId)); + } + break; + } + + case 'status': { + if (msg.text === 'token_budget' && msg.tokenBudget) { + setTokenBudget(msg.tokenBudget as Record); + } else if (msg.text && sid) { + onSessionProcessing?.(sid, { + statusText: msg.text as string, + canInterrupt: msg.canInterrupt !== false, + }); + } + break; + } + + // text, tool_use, tool_result, thinking, interactive_prompt, task_notification + // → already routed to store above, no UI side effects needed + default: + break; + } + }; + + return subscribe(handleEvent); }, [ - latestMessage, + subscribe, provider, selectedSession, currentSessionId, - setCurrentSessionId, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setTokenBudget, setPendingPermissionRequests, - pendingViewSessionRef, streamTimerRef, accumulatedStreamRef, - onSessionInactive, - onSessionActive, + lastSeqRef, + statusCheckSentAtRef, onSessionProcessing, - onSessionNotProcessing, - onNavigateToSession, + onSessionIdle, onWebSocketReconnect, sessionStore, - paletteOps, ]); } diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index d11ff3cb..652edd87 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -2,9 +2,10 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import type { MutableRefObject } from 'react'; import { authenticatedFetch } from '../../../utils/api'; +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'; +import type { ChatMessage } from '../types/types'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { normalizedToChatMessages } from './useChatMessages'; @@ -12,10 +13,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 +21,13 @@ interface UseChatSessionStateArgs { autoScrollToBottom?: boolean; externalMessageUpdate?: number; newSessionTrigger?: number; - processingSessions?: Set; + processingSessions?: SessionActivityMap; + onSessionIdle?: MarkSessionIdle; resetStreamingState: () => void; - pendingViewSessionRef: MutableRefObject; + /** When each session's `chat.subscribe` was last sent; guards stale idle acks. */ + statusCheckSentAtRef: MutableRefObject>; + /** Highest live seq observed per session; sent as `lastSeq` on subscribe. */ + lastSeqRef: MutableRefObject>; sessionStore: SessionStore; } @@ -99,21 +100,20 @@ export function useChatSessionState({ externalMessageUpdate, newSessionTrigger, processingSessions, + onSessionIdle, resetStreamingState, - pendingViewSessionRef, + statusCheckSentAtRef, + lastSeqRef, 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,13 +170,8 @@ export function useChatSessionState({ * - No coupling to unrelated external update signals. */ resetStreamingState(); - pendingViewSessionRef.current = null; - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); setCurrentSessionId(null); setPendingUserMessage(null); - sessionStorage.removeItem('cursorSessionId'); messagesOffsetRef.current = 0; setHasMoreMessages(false); setTotalMessages(0); @@ -204,13 +199,30 @@ 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 — never stale local UI state from the last time it was + // open. Session ids are concrete before any send, so no pending + // placeholder entry exists anymore. + const sessionActivity = (activeSessionId && processingSessions?.get(activeSessionId)) || null; + const isProcessing = sessionActivity !== null; + const canAbortSession = isProcessing && sessionActivity.canInterrupt; + + // 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); @@ -316,18 +328,12 @@ export function useChatSessionState({ if (allMessagesLoadedRef.current) return false; if (!hasMoreMessages || !selectedSession || !selectedProject) return false; - const sessionProvider = selectedSession.__provider || 'claude'; - isLoadingMoreRef.current = true; const previousScrollHeight = container.scrollHeight; const previousScrollTop = container.scrollTop; try { const slot = await sessionStore.fetchMore(selectedSession.id, { - provider: sessionProvider as LLMProvider, - // DB-assigned projectId replaces the legacy folder-derived name. - projectId: selectedProject.projectId, - projectPath: selectedProject.fullPath || selectedProject.path || '', limit: MESSAGES_PER_PAGE, }); if (!slot || slot.serverMessages.length === 0) return false; @@ -429,19 +435,15 @@ export function useChatSessionState({ // Main session loading effect — store-based useEffect(() => { if (!selectedSession || !selectedProject) { - // A new provider run can be in flight before the router has a canonical - // selectedSession. Keep the processing banner alive until complete/error. - if (pendingViewSessionRef.current) { + // A freshly created session can be mid-run before the router has a + // canonical selectedSession (the URL effect synthesizes one on the + // next render). Keep the active view intact instead of wiping it. + if (currentSessionId && processingSessionsRef.current?.has(currentSessionId)) { return; } resetStreamingState(); - pendingViewSessionRef.current = null; - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); setCurrentSessionId(null); - sessionStorage.removeItem('cursorSessionId'); messagesOffsetRef.current = 0; setHasMoreMessages(false); setTotalMessages(0); @@ -450,8 +452,7 @@ export function useChatSessionState({ return; } - const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude'; - const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`; + const sessionKey = `${selectedSession.id}:${selectedProject.projectId}`; // Skip if already loaded and fresh if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) { @@ -461,9 +462,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,17 +480,24 @@ export function useChatSessionState({ if (sessionChanged) { setTokenBudget(null); - setIsLoading(false); } setCurrentSessionId(selectedSession.id); - if (provider === 'cursor') { - sessionStorage.setItem('cursorSessionId', selectedSession.id); - } - // Check session status + // Subscribe to the session's live run (if any): the ack reconciles the + // processing indicator, re-attaches a mid-flight stream to this socket, + // and replays any live events missed since `lastSeq`. Recording the send + // time lets the ack handler discard idle acks that a newer request has + // since outdated. if (ws) { - sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider }); + statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); + sendMessage({ + type: 'chat.subscribe', + sessions: [{ + sessionId: selectedSession.id, + lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0, + }], + }); } lastLoadedSessionKeyRef.current = sessionKey; @@ -500,9 +505,6 @@ export function useChatSessionState({ // Fetch from server → store updates → chatMessages re-derives automatically setIsLoadingSessionMessages(true); sessionStore.fetchFromServer(selectedSession.id, { - provider: (selectedSession.__provider || provider) as LLMProvider, - projectId: selectedProject.projectId, - projectPath: selectedProject.fullPath || selectedProject.path || '', limit: MESSAGES_PER_PAGE, offset: 0, }).then(slot => { @@ -516,11 +518,12 @@ export function useChatSessionState({ setIsLoadingSessionMessages(false); }); }, [ - pendingViewSessionRef, resetStreamingState, selectedProject, selectedSession?.id, sendMessage, + statusCheckSentAtRef, + lastSeqRef, ws, sessionStore, ]); @@ -531,15 +534,9 @@ export function useChatSessionState({ const reloadExternalMessages = async () => { try { - const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude'; - // Skip store refresh during active streaming - if (!isLoading) { - await sessionStore.refreshFromServer(selectedSession.id, { - provider: (selectedSession.__provider || provider) as LLMProvider, - projectId: selectedProject.projectId, - projectPath: selectedProject.fullPath || selectedProject.path || '', - }); + if (!isProcessing) { + await sessionStore.refreshFromServer(selectedSession.id); if (Boolean(autoScrollToBottom) && isNearBottom()) { setTimeout(() => scrollToBottom(), 200); @@ -559,7 +556,7 @@ export function useChatSessionState({ selectedProject, selectedSession, sessionStore, - isLoading, + isProcessing, ]); // Search navigation target @@ -585,13 +582,9 @@ export function useChatSessionState({ const scrollToTarget = async () => { if (!allMessagesLoadedRef.current && selectedSession && selectedProject) { - const sessionProvider = selectedSession.__provider || 'claude'; try { // Load all messages into the store for search navigation const slot = await sessionStore.fetchFromServer(selectedSession.id, { - provider: sessionProvider as LLMProvider, - projectId: selectedProject.projectId, - projectPath: selectedProject.fullPath || selectedProject.path || '', limit: null, offset: 0, }); @@ -665,17 +658,10 @@ export function useChatSessionState({ setTokenBudget(null); return; } - const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') { - setTokenBudget(null); - return; - } - const fetchInitialTokenUsage = async () => { try { - // Token usage endpoint is now keyed by the DB projectId. - const params = new URLSearchParams({ provider: sessionProvider }); - const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`; + // The backend resolves the provider from the indexed session row. + const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`; const response = await authenticatedFetch(url); if (response.ok) { setTokenBudget(await response.json()); @@ -687,7 +673,7 @@ export function useChatSessionState({ } }; fetchInitialTokenUsage(); - }, [selectedProject, selectedSession?.id, selectedSession?.__provider]); + }, [selectedProject, selectedSession?.id]); const visibleMessages = useMemo(() => { if (chatMessages.length <= visibleMessageCount) return chatMessages; @@ -726,16 +712,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(() => { @@ -757,8 +733,6 @@ export function useChatSessionState({ const loadAllMessages = useCallback(async () => { if (!selectedSession || !selectedProject) return; if (isLoadingAllMessages) return; - const sessionProvider = selectedSession.__provider || 'claude'; - const requestSessionId = selectedSession.id; allMessagesLoadedRef.current = true; isLoadingMoreRef.current = true; @@ -771,9 +745,6 @@ export function useChatSessionState({ try { const slot = await sessionStore.fetchFromServer(requestSessionId, { - provider: sessionProvider as LLMProvider, - projectId: selectedProject.projectId, - projectPath: selectedProject.fullPath || selectedProject.path || '', limit: null, offset: 0, }); @@ -817,16 +788,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 +809,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..60ee18fd 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; @@ -102,20 +107,24 @@ export type SessionNavigationOptions = { replace?: boolean; }; +export type SessionEstablishedContext = { + provider: LLMProvider; + project: Project; + summary?: string | null; +}; + export interface ChatInterfaceProps { selectedProject: Project | null; selectedSession: ProjectSession | null; ws: WebSocket | null; sendMessage: (message: unknown) => void; - 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; + onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onShowSettings?: () => void; autoExpandTools?: boolean; showRawParameters?: boolean; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 01ecb68a..5efe6af4 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -2,10 +2,10 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; +import { useWebSocket } from '../../../contexts/WebSocketContext'; import PermissionContext from '../../../contexts/PermissionContext'; import { QuickSettingsPanel } from '../../quick-settings-panel'; import type { ChatInterfaceProps, Provider } from '../types/types'; -import type { LLMProvider } from '../../../types/app'; import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; @@ -17,24 +17,18 @@ import ChatComposer from './subcomponents/ChatComposer'; import CommandResultModal from './subcomponents/CommandResultModal'; -type PendingViewSession = { - startedAt: number; -}; - function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, - latestMessage, onFileOpen, onInputFocusChange, - onSessionActive, - onSessionInactive, onSessionProcessing, - onSessionNotProcessing, + onSessionIdle, processingSessions, onNavigateToSession, + onSessionEstablished, onShowSettings, autoExpandTools, showRawParameters, @@ -46,12 +40,19 @@ function ChatInterface({ onShowAllTasks, }: ChatInterfaceProps) { const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); + const { subscribe } = useWebSocket(); const { t } = useTranslation('chat'); const sessionStore = useSessionStore(); const streamTimerRef = useRef(null); const accumulatedStreamRef = useRef(''); - const pendingViewSessionRef = useRef(null); + // When each session's `chat.subscribe` was last sent; idle acks older than + // a later local request are discarded as stale. + const statusCheckSentAtRef = useRef(new Map()); + // Highest live `seq` observed per session. Written by the realtime handler + // on every sequenced frame, read whenever a `chat.subscribe` is sent so the + // server replays only the events this client actually missed. + const lastSeqRef = useRef(new Map()); const resetStreamingState = useCallback(() => { if (streamTimerRef.current) { @@ -92,16 +93,15 @@ function ChatInterface({ const { chatMessages, addMessage, - isLoading, - setIsLoading, + sessionActivity, + isProcessing, + canAbortSession, currentSessionId, setCurrentSessionId, isLoadingSessionMessages, isLoadingMoreMessages, hasMoreMessages, totalMessages, - canAbortSession, - setCanAbortSession, isUserScrolledUp, setIsUserScrolledUp, tokenBudget, @@ -114,8 +114,6 @@ function ChatInterface({ isLoadingAllMessages, loadAllJustFinished, showLoadAllOverlay, - claudeStatus, - setClaudeStatus, createDiff, scrollContainerRef, scrollToBottom, @@ -130,11 +128,22 @@ function ChatInterface({ externalMessageUpdate, newSessionTrigger, processingSessions, + onSessionIdle, resetStreamingState, - pendingViewSessionRef, + statusCheckSentAtRef, + lastSeqRef, sessionStore, }); + // Brand-new conversation: the composer allocated a stable session id via + // the session gateway before the first send. Record it locally and put it + // in the URL — this id never changes again, so there is no later handoff. + const handleSessionEstablished = useCallback>((sessionId, context) => { + setCurrentSessionId(sessionId); + onSessionEstablished?.(sessionId, context); + onNavigateToSession?.(sessionId); + }, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]); + const { input, setInput, @@ -191,66 +200,58 @@ function ChatInterface({ codexModel, geminiModel, opencodeModel, - isLoading, + isLoading: isProcessing, canAbortSession, tokenBudget, sendMessage, sendByCtrlEnter, - onSessionActive, onSessionProcessing, + onSessionEstablished: handleSessionEstablished, 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-subscribe — the + // `chat_subscribed` ack restores or clears the activity indicator, replays + // missed live events, and re-attaches a still-running stream to this socket. const handleWebSocketReconnect = useCallback(async () => { if (!selectedProject || !selectedSession) return; - const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; - await sessionStore.refreshFromServer(selectedSession.id, { - provider: (selectedSession.__provider || providerVal) as LLMProvider, - // Use DB projectId; legacy folder-derived projectName is no longer accepted here. - projectId: selectedProject.projectId, - projectPath: selectedProject.fullPath || selectedProject.path || '', + await sessionStore.refreshFromServer(selectedSession.id); + statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); + sendMessage({ + type: 'chat.subscribe', + sessions: [{ + sessionId: selectedSession.id, + lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0, + }], }); - setIsLoading(false); - setCanAbortSession(false); - }, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]); + }, [selectedProject, selectedSession, sendMessage, sessionStore]); useChatRealtimeHandlers({ - latestMessage, + subscribe, provider, selectedSession, currentSessionId, - setCurrentSessionId, - setIsLoading, - setCanAbortSession, - setClaudeStatus, setTokenBudget, setPendingPermissionRequests, - pendingViewSessionRef, streamTimerRef, accumulatedStreamRef, - onSessionInactive, - onSessionActive, + lastSeqRef, + statusCheckSentAtRef, onSessionProcessing, - onSessionNotProcessing, - onNavigateToSession, + onSessionIdle, onWebSocketReconnect: handleWebSocketReconnect, sessionStore, }); useEffect(() => { - if (!isLoading || !canAbortSession) { + if (!canAbortSession) { return; } @@ -267,7 +268,7 @@ function ChatInterface({ return () => { document.removeEventListener('keydown', handleGlobalEscape, { capture: true }); }; - }, [canAbortSession, handleAbortSession, isLoading]); + }, [canAbortSession, handleAbortSession]); useEffect(() => { return () => { @@ -314,6 +315,7 @@ function ChatInterface({ onWheel={handleScroll} onTouchMove={handleScroll} isLoadingSessionMessages={isLoadingSessionMessages} + isProcessing={isProcessing} chatMessages={chatMessages} selectedSession={selectedSession} currentSessionId={currentSessionId} @@ -362,10 +364,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/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index f4ba1161..5573b31f 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -19,6 +19,8 @@ interface ChatMessagesPaneProps { onWheel: () => void; onTouchMove: () => void; isLoadingSessionMessages: boolean; + /** True while the viewed session has an active provider run in flight. */ + isProcessing?: boolean; chatMessages: ChatMessage[]; selectedSession: ProjectSession | null; currentSessionId: string | null; @@ -68,6 +70,7 @@ export default function ChatMessagesPane({ onWheel, onTouchMove, isLoadingSessionMessages, + isProcessing = false, chatMessages, selectedSession, currentSessionId, @@ -147,7 +150,7 @@ export default function ChatMessagesPane({ onTouchMove={onTouchMove} className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4" > - {isLoadingSessionMessages && chatMessages.length === 0 ? ( + {(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
diff --git a/src/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/command-palette/sources/useSessionsSource.ts b/src/components/command-palette/sources/useSessionsSource.ts index 3623c798..ec045b26 100644 --- a/src/components/command-palette/sources/useSessionsSource.ts +++ b/src/components/command-palette/sources/useSessionsSource.ts @@ -11,10 +11,6 @@ export type SessionResult = { interface SessionsResponse { sessions?: ProjectSession[]; - cursorSessions?: ProjectSession[]; - codexSessions?: ProjectSession[]; - geminiSessions?: ProjectSession[]; - opencodeSessions?: ProjectSession[]; } export function useSessionsSource(projectId: string | undefined, enabled: boolean) { @@ -29,17 +25,10 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea ); }, parse: (data) => { - const all: ProjectSession[] = [ - ...(data.sessions ?? []), - ...(data.cursorSessions ?? []), - ...(data.codexSessions ?? []), - ...(data.geminiSessions ?? []), - ...(data.opencodeSessions ?? []), - ]; - return all.map((s) => ({ + return (data.sessions ?? []).map((s) => ({ id: s.id, label: (s.title || s.summary || s.name || s.id) as string, - provider: s.__provider, + provider: (s.__provider || s.provider) as LLMProvider | undefined, })); }, }); diff --git a/src/components/llm-logo-provider/ClaudeLogo.tsx b/src/components/llm-logo-provider/ClaudeLogo.tsx index d15a0711..5074e2fd 100644 --- a/src/components/llm-logo-provider/ClaudeLogo.tsx +++ b/src/components/llm-logo-provider/ClaudeLogo.tsx @@ -1,14 +1,27 @@ -import React from 'react'; - type ClaudeLogoProps = { className?: string; }; -const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => { - return ( - Claude - ); -}; +const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => ( + + + + +); export default ClaudeLogo; diff --git a/src/components/llm-logo-provider/CodexLogo.tsx b/src/components/llm-logo-provider/CodexLogo.tsx index 0c3a65f0..2a189600 100644 --- a/src/components/llm-logo-provider/CodexLogo.tsx +++ b/src/components/llm-logo-provider/CodexLogo.tsx @@ -1,20 +1,21 @@ -import React from 'react'; -import { useTheme } from '../../contexts/ThemeContext'; - type CodexLogoProps = { className?: string; }; -const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => { - const { isDarkMode } = useTheme(); - - return ( - Codex ( + + - ); -}; + +); export default CodexLogo; diff --git a/src/components/llm-logo-provider/CursorLogo.tsx b/src/components/llm-logo-provider/CursorLogo.tsx index a44064ac..916de4f4 100644 --- a/src/components/llm-logo-provider/CursorLogo.tsx +++ b/src/components/llm-logo-provider/CursorLogo.tsx @@ -1,20 +1,26 @@ -import React from 'react'; -import { useTheme } from '../../contexts/ThemeContext'; - type CursorLogoProps = { className?: string; }; -const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => { - const { isDarkMode } = useTheme(); - - return ( - Cursor ( + + - ); -}; + + + + + +); export default CursorLogo; diff --git a/src/components/llm-logo-provider/GeminiLogo.tsx b/src/components/llm-logo-provider/GeminiLogo.tsx index 9954dd78..17212bd1 100644 --- a/src/components/llm-logo-provider/GeminiLogo.tsx +++ b/src/components/llm-logo-provider/GeminiLogo.tsx @@ -1,7 +1,263 @@ -const GeminiLogo = ({className = 'w-5 h-5'}) => { +import { useId } from 'react'; + +type GeminiLogoProps = { + className?: string; +}; + +const GeminiLogo = ({ className = 'w-5 h-5' }: GeminiLogoProps) => { + const id = useId().replace(/:/g, ''); + const maskId = `${id}-gemini-mask`; + const gradientId = `${id}-gemini-gradient`; + const filterIds = Array.from({ length: 11 }, (_, index) => `${id}-gemini-filter-${index}`); + return ( - Gemini + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; -export default GeminiLogo; \ No newline at end of file +export default GeminiLogo; diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index 68b03b29..a7398795 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -1,9 +1,12 @@ import type { Dispatch, SetStateAction } from 'react'; import type { AppTab, Project, ProjectSession } from '../../../types/app'; -import type { SessionNavigationOptions } from '../../chat/types/types'; - -export type SessionLifecycleHandler = (sessionId?: string | null) => void; +import type { + MarkSessionIdle, + MarkSessionProcessing, + SessionActivityMap, +} from '../../../hooks/useSessionProtection'; +import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types'; export type TaskMasterTask = { id: string | number; @@ -41,17 +44,15 @@ export type MainContentProps = { setActiveTab: Dispatch>; ws: WebSocket | null; sendMessage: (message: unknown) => void; - latestMessage: unknown; isMobile: boolean; 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; + onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void; onShowSettings: () => void; externalMessageUpdate: number; newSessionTrigger: number; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 2f98b7f1..12cfe7aa 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -38,17 +38,15 @@ function MainContent({ setActiveTab, ws, sendMessage, - latestMessage, isMobile, onMenuClick, isLoading, onInputFocusChange, - onSessionActive, - onSessionInactive, onSessionProcessing, - onSessionNotProcessing, + onSessionIdle, processingSessions, onNavigateToSession, + onSessionEstablished, onShowSettings, externalMessageUpdate, newSessionTrigger, @@ -129,15 +127,13 @@ function MainContent({ selectedSession={selectedSession} ws={ws} sendMessage={sendMessage} - latestMessage={latestMessage} onFileOpen={handleFileOpen} onInputFocusChange={onInputFocusChange} - onSessionActive={onSessionActive} - onSessionInactive={onSessionInactive} onSessionProcessing={onSessionProcessing} - onSessionNotProcessing={onSessionNotProcessing} + onSessionIdle={onSessionIdle} processingSessions={processingSessions} onNavigateToSession={onNavigateToSession} + onSessionEstablished={onSessionEstablished} onShowSettings={onShowSettings} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index ba559442..4d907a8b 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'; import { api } from '../../../utils/api'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; +import type { SessionActivityMap } from '../../../hooks/useSessionProtection'; import type { ArchivedProjectListItem, ArchivedSessionListItem, @@ -81,6 +82,7 @@ type UseSidebarControllerArgs = { projects: Project[]; selectedProject: Project | null; selectedSession: ProjectSession | null; + activeSessions: SessionActivityMap; isLoading: boolean; isMobile: boolean; t: TFunction; @@ -100,6 +102,7 @@ export function useSidebarController({ projects, selectedProject, selectedSession: _selectedSession, + activeSessions, isLoading, isMobile, t, @@ -146,6 +149,8 @@ export function useSidebarController({ const onRefreshRef = useRef(onRefresh); const isSidebarCollapsed = !isMobile && !sidebarVisible; + const activeSessionIds = useMemo(() => new Set(activeSessions.keys()), [activeSessions]); + const runningSessionsCount = activeSessionIds.size; useEffect(() => { const timer = setInterval(() => { @@ -582,9 +587,35 @@ export function useSidebarController({ [projectSortOrder, projectsWithResolvedStarState], ); + const runningProjects = useMemo(() => { + if (activeSessionIds.size === 0) { + return []; + } + + return sortedProjects.reduce((acc, project) => { + const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); + const runningCount = sessions.length; + + if (runningCount === 0) { + return acc; + } + + acc.push({ + ...project, + sessions, + sessionMeta: { + ...project.sessionMeta, + total: runningCount, + hasMore: false, + }, + }); + return acc; + }, []); + }, [activeSessionIds, sortedProjects]); + const filteredProjects = useMemo( - () => filterProjects(sortedProjects, debouncedSearchQuery), - [debouncedSearchQuery, sortedProjects], + () => filterProjects(searchMode === 'running' ? runningProjects : sortedProjects, debouncedSearchQuery), + [debouncedSearchQuery, runningProjects, searchMode, sortedProjects], ); const filteredArchivedSessions = useMemo(() => { @@ -914,6 +945,7 @@ export function useSidebarController({ sessionDeleteConfirmation, showVersionModal, filteredProjects, + runningSessionsCount, archivedProjects: filteredArchivedProjects, archivedSessions: filteredArchivedSessions, archivedSessionsCount: archivedProjects.length + archivedSessions.length, diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index f8c31be2..672fdd34 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -1,7 +1,8 @@ import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app'; +import type { SessionActivityMap } from '../../../hooks/useSessionProtection'; export type ProjectSortOrder = 'name' | 'date'; -export type SidebarSearchMode = 'projects' | 'conversations' | 'archived'; +export type SidebarSearchMode = 'projects' | 'conversations' | 'running' | 'archived'; export type ArchivedProjectListItem = Project & { isArchived: true }; export type SessionWithProvider = ProjectSession & { @@ -40,6 +41,7 @@ export type SidebarProps = { projects: Project[]; selectedProject: Project | null; selectedSession: ProjectSession | null; + activeSessions: SessionActivityMap; onProjectSelect: (project: Project) => void; onSessionSelect: (session: ProjectSession) => void; onNewSession: (project: Project) => void; @@ -59,10 +61,6 @@ export type SidebarProps = { }; export type SessionViewModel = { - isCursorSession: boolean; - isCodexSession: boolean; - isGeminiSession: boolean; - isOpenCodeSession: boolean; isActive: boolean; sessionName: string; sessionTime: string; diff --git a/src/components/sidebar/utils/utils.ts b/src/components/sidebar/utils/utils.ts index 57e28b1d..23010915 100644 --- a/src/components/sidebar/utils/utils.ts +++ b/src/components/sidebar/utils/utils.ts @@ -1,6 +1,6 @@ import type { TFunction } from 'i18next'; -import type { Project } from '../../../types/app'; +import type { LLMProvider, Project, ProjectSession } from '../../../types/app'; import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types'; export const readProjectSortOrder = (): ProjectSortOrder => { @@ -61,6 +61,13 @@ const getUpdatedTimestamp = (session: SessionWithProvider): string => { return String(session.lastActivity || ''); }; +const getSessionProvider = (session: ProjectSession): LLMProvider => { + const provider = session.__provider ?? session.provider; + return typeof provider === 'string' && provider.trim() + ? provider as LLMProvider + : 'claude'; +}; + export const getSessionDate = (session: SessionWithProvider): Date => { return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0); }; @@ -82,10 +89,6 @@ export const createSessionViewModel = ( const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60)); return { - isCursorSession: session.__provider === 'cursor', - isCodexSession: session.__provider === 'codex', - isGeminiSession: session.__provider === 'gemini', - isOpenCodeSession: session.__provider === 'opencode', isActive: diffInMinutes < 10, sessionName: getSessionName(session, t), sessionTime: getSessionTime(session), @@ -94,32 +97,10 @@ export const createSessionViewModel = ( }; export const getAllSessions = (project: Project): SessionWithProvider[] => { - const claudeSessions = [...(project.sessions || [])].map((session) => ({ + return (project.sessions || []).map((session) => ({ ...session, - __provider: 'claude' as const, - })); - - const cursorSessions = (project.cursorSessions || []).map((session) => ({ - ...session, - __provider: 'cursor' as const, - })); - - const codexSessions = (project.codexSessions || []).map((session) => ({ - ...session, - __provider: 'codex' as const, - })); - - const geminiSessions = (project.geminiSessions || []).map((session) => ({ - ...session, - __provider: 'gemini' as const, - })); - - const opencodeSessions = (project.opencodeSessions || []).map((session) => ({ - ...session, - __provider: 'opencode' as const, - })); - - return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort( + __provider: getSessionProvider(session), + })).sort( (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), ); }; diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index ebc046c5..15d96990 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -25,6 +25,7 @@ function Sidebar({ projects, selectedProject, selectedSession, + activeSessions, onProjectSelect, onSessionSelect, onNewSession, @@ -70,6 +71,7 @@ function Sidebar({ isSearching, searchProgress, clearConversationResults, + runningSessionsCount, deletingProjects, deleteConfirmation, sessionDeleteConfirmation, @@ -113,6 +115,7 @@ function Sidebar({ projects, selectedProject, selectedSession, + activeSessions, isLoading, isMobile, t, @@ -159,6 +162,8 @@ function Sidebar({ mcpServerStatus, getProjectSessions, loadingMoreProjects, + activeSessions, + forceExpanded: searchMode === 'running', isProjectStarred, onEditingNameChange: setEditingName, onToggleProject: toggleProject, @@ -229,6 +234,7 @@ function Sidebar({ isMobile={isMobile} isLoading={isLoading} projects={projects} + runningSessionsCount={runningSessionsCount} archivedProjects={archivedProjects} archivedSessions={archivedSessions} archivedSessionsCount={archivedSessionsCount} diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 5ce63b8b..ce8554e2 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -1,16 +1,18 @@ import { type ReactNode } from 'react'; -import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react'; +import { Activity, Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react'; import type { TFunction } from 'i18next'; + import { ScrollArea } from '../../../../shared/view/ui'; import type { Project } from '../../../../types/app'; import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController'; import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; +import { getAllSessions } from '../../utils/utils'; + import SidebarFooter from './SidebarFooter'; import SidebarHeader from './SidebarHeader'; import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList'; -import { getAllSessions } from '../../utils/utils'; function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) { const parts: ReactNode[] = []; @@ -114,6 +116,7 @@ type SidebarContentProps = { isMobile: boolean; isLoading: boolean; projects: Project[]; + runningSessionsCount: number; archivedProjects: ArchivedProjectListItem[]; archivedSessions: ArchivedSessionListItem[]; archivedSessionsCount: number; @@ -152,6 +155,7 @@ export default function SidebarContent({ isMobile, isLoading, projects, + runningSessionsCount, archivedProjects, archivedSessions, archivedSessionsCount, @@ -196,6 +200,7 @@ export default function SidebarContent({ isMobile={isMobile} isLoading={isLoading} projectsCount={projects.length} + runningSessionsCount={runningSessionsCount} archivedSessionsCount={archivedSessionsCount} isArchivedSessionsLoading={isArchivedSessionsLoading} searchFilter={searchFilter} @@ -307,6 +312,39 @@ export default function SidebarContent({ ))}
) : null + ) : searchMode === 'running' ? ( + projectListProps.filteredProjects.length === 0 ? ( +
+
+ +
+

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

+

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

+
+ ) : ( +
+
+
+ + + + + {t('running.title', 'Running now')} + +
+ + {runningSessionsCount} + +
+ +
+ ) ) : searchMode === 'archived' ? ( isArchivedSessionsLoading ? (
@@ -358,7 +396,7 @@ export default function SidebarContent({ {project.displayName} - + {t('archived.projectArchived', 'Project archived')}
@@ -448,7 +486,7 @@ export default function SidebarContent({ {group.projectDisplayName} {group.isProjectArchived && ( - + {t('archived.projectArchived', 'Project archived')} )} diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx index 5117b8db..8eabab2a 100644 --- a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx @@ -1,9 +1,11 @@ -import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react'; +import { Activity, Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react'; import type { TFunction } from 'i18next'; + import { Button, Input, Tooltip } from '../../../../shared/view/ui'; import { IS_PLATFORM } from '../../../../constants/config'; import { cn } from '../../../../lib/utils'; import type { SidebarSearchMode } from '../../types/types'; + import GitHubStarBadge from './GitHubStarBadge'; const MOD_KEY = @@ -14,6 +16,7 @@ type SidebarHeaderProps = { isMobile: boolean; isLoading: boolean; projectsCount: number; + runningSessionsCount: number; archivedSessionsCount: number; isArchivedSessionsLoading: boolean; searchFilter: string; @@ -33,6 +36,7 @@ export default function SidebarHeader({ isMobile, isLoading, projectsCount, + runningSessionsCount, archivedSessionsCount, isArchivedSessionsLoading, searchFilter, @@ -46,12 +50,15 @@ export default function SidebarHeader({ onCollapseSidebar, t, }: SidebarHeaderProps) { - const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading; + const showSearchTools = (projectsCount > 0 || runningSessionsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading; const searchPlaceholder = searchMode === 'conversations' ? t('search.conversationsPlaceholder') : searchMode === 'archived' ? t('search.archivedPlaceholder', 'Search archived sessions...') - : t('projects.searchPlaceholder'); + : searchMode === 'running' + ? t('search.runningPlaceholder', 'Search running sessions...') + : t('projects.searchPlaceholder'); + const runningBadgeText = runningSessionsCount > 99 ? '99+' : String(runningSessionsCount); const LogoBlock = () => (
@@ -153,6 +160,29 @@ export default function SidebarHeader({ {t('search.modeConversations')} + + + + + + - {!sessionView.isCursorSession && ( + {!isProcessing && (