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