diff --git a/server/cursor-cli.js b/server/cursor-cli.js index 66af16ef..1d5a7d79 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -150,7 +150,6 @@ async function spawnCursor(command, options = {}, ws) { try { const response = JSON.parse(line); - console.log('Parsed JSON response:', response); // Handle different message types switch (response.type) { @@ -159,7 +158,6 @@ async function spawnCursor(command, options = {}, ws) { // Capture session ID if (response.session_id && !capturedSessionId) { capturedSessionId = response.session_id; - console.log('Captured session ID:', capturedSessionId); // Update process key with captured session ID if (processKey !== capturedSessionId) { @@ -197,7 +195,6 @@ async function spawnCursor(command, options = {}, ws) { case 'result': { // Session complete — send stream end + lifecycle complete with result payload - console.log('Cursor session result:', response); const resultText = typeof response.result === 'string' ? response.result : ''; ws.send(createNormalizedMessage({ kind: 'complete', @@ -213,8 +210,6 @@ async function spawnCursor(command, options = {}, ws) { // Unknown message types — ignore. } } catch (parseError) { - console.log('Non-JSON response:', line); - if (shouldSuppressForTrustRetry(line)) { return; } @@ -228,7 +223,6 @@ async function spawnCursor(command, options = {}, ws) { // Handle stdout (streaming JSON responses) cursorProcess.stdout.on('data', (data) => { const rawOutput = data.toString(); - console.log('Cursor CLI stdout:', rawOutput); // Stream chunks can split JSON objects across packets; keep trailing partial line. stdoutLineBuffer += rawOutput; @@ -254,8 +248,6 @@ async function spawnCursor(command, options = {}, ws) { // Handle process completion cursorProcess.on('close', async (code) => { - console.log(`Cursor CLI process exited with code ${code}`); - const finalSessionId = capturedSessionId || sessionId || processKey; activeCursorProcesses.delete(finalSessionId); diff --git a/server/gemini-cli.js b/server/gemini-cli.js index 2e68a938..1f45682c 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -1,19 +1,123 @@ import { spawn } from 'child_process'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + import crossSpawn from 'cross-spawn'; -// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; -import { promises as fs } from 'fs'; -import path from 'path'; -import os from 'os'; import sessionManager from './sessionManager.js'; import GeminiResponseHandler from './gemini-response-handler.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { providerAuthService } from './modules/providers/services/provider-auth.service.js'; import { createNormalizedMessage } from './shared/utils.js'; +// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) +const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; + let activeGeminiProcesses = new Map(); // Track active processes by session ID +function mapGeminiExitCodeToMessage(exitCode) { + switch (exitCode) { + case 42: + return 'Gemini rejected the request input (exit code 42).'; + case 44: + return 'Gemini sandbox error (exit code 44). Check local sandbox/container settings.'; + case 52: + return 'Gemini configuration error (exit code 52). Check your Gemini settings files for invalid JSON/config.'; + case 53: + return 'Gemini conversation turn limit reached (exit code 53). Start a new Gemini session.'; + default: + return null; + } +} + +const GEMINI_AUTH_ENV_KEYS = [ + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_PROJECT_ID', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_APPLICATION_CREDENTIALS' +]; + +function parseEnvFileContent(content) { + const parsed = {}; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const exportPrefix = 'export '; + const normalizedLine = line.startsWith(exportPrefix) ? line.slice(exportPrefix.length).trim() : line; + const separatorIndex = normalizedLine.indexOf('='); + + if (separatorIndex <= 0) { + continue; + } + + const key = normalizedLine.slice(0, separatorIndex).trim(); + if (!key) { + continue; + } + + let value = normalizedLine.slice(separatorIndex + 1).trim(); + const hasDoubleQuotes = value.startsWith('"') && value.endsWith('"'); + const hasSingleQuotes = value.startsWith('\'') && value.endsWith('\''); + + if (hasDoubleQuotes || hasSingleQuotes) { + value = value.slice(1, -1); + } else { + // Support inline comments in unquoted values: KEY=value # comment + value = value.replace(/\s+#.*$/, '').trim(); + } + + parsed[key] = value; + } + + return parsed; +} + +async function loadGeminiUserLevelEnv() { + const geminiCliHome = (process.env.GEMINI_CLI_HOME || '').trim() || os.homedir(); + const envCandidates = [ + path.join(geminiCliHome, '.gemini', '.env'), + path.join(geminiCliHome, '.env') + ]; + + for (const envPath of envCandidates) { + try { + await fs.access(envPath); + const content = await fs.readFile(envPath, 'utf8'); + return parseEnvFileContent(content); + } catch { + // Keep scanning for the next candidate. + } + } + + return {}; +} + +async function buildGeminiProcessEnv() { + const processEnv = { ...process.env }; + if (processEnv.GEMINI_API_KEY || processEnv.GOOGLE_API_KEY || processEnv.GOOGLE_APPLICATION_CREDENTIALS) { + return processEnv; + } + + // Gemini CLI docs recommend ~/.gemini/.env for persistent headless auth settings. + // When the server process was launched without shell profile variables, we still + // want the spawned CLI process to inherit those user-level credentials. + const userEnv = await loadGeminiUserLevelEnv(); + for (const key of GEMINI_AUTH_ENV_KEYS) { + if (!processEnv[key] && userEnv[key]) { + processEnv[key] = userEnv[key]; + } + } + + return processEnv; +} + async function spawnGemini(command, options = {}, ws) { const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process @@ -100,6 +204,11 @@ async function spawnGemini(command, options = {}, ws) { args.push('--debug'); } + // This integration runs Gemini in headless mode and cannot answer trust prompts. + // Skip folder-trust interactivity so authenticated runs don't fail with + // FatalUntrustedWorkspaceError in previously unseen directories. + args.push('--skip-trust'); + // Add MCP config flag only if MCP servers are configured try { const geminiConfigPath = path.join(os.homedir(), '.gemini.json'); @@ -154,9 +263,6 @@ async function spawnGemini(command, options = {}, ws) { // Try to find gemini in PATH first, then fall back to environment variable const geminiPath = process.env.GEMINI_PATH || 'gemini'; - console.log('Spawning Gemini CLI:', geminiPath, args.join(' ')); - console.log('Working directory:', workingDir); - let spawnCmd = geminiPath; let spawnArgs = args; @@ -168,11 +274,13 @@ async function spawnGemini(command, options = {}, ws) { spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args]; } + const spawnEnv = await buildGeminiProcessEnv(); + return new Promise((resolve, reject) => { const geminiProcess = spawnFunction(spawnCmd, spawnArgs, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env } // Inherit all environment variables + env: spawnEnv }); let terminalNotificationSent = false; let terminalFailureReason = null; @@ -276,12 +384,43 @@ async function spawnGemini(command, options = {}, ws) { } }, onInit: (event) => { - if (capturedSessionId) { - const sess = sessionManager.getSession(capturedSessionId); - if (sess && !sess.cliSessionId) { - sess.cliSessionId = event.session_id; - sessionManager.saveSession(capturedSessionId); + const discoveredSessionId = event?.session_id; + if (!discoveredSessionId) { + return; + } + + // New Gemini sessions announce their canonical ID asynchronously via the + // initial `init` stream event. Avoid synthetic IDs and only register + // the session once that real ID is known (same model used by Claude/Codex). + if (!capturedSessionId) { + capturedSessionId = discoveredSessionId; + + sessionManager.createSession(capturedSessionId, cwd || process.cwd()); + if (command) { + sessionManager.addMessage(capturedSessionId, 'user', command); } + + if (processKey !== capturedSessionId) { + activeGeminiProcesses.delete(processKey); + activeGeminiProcesses.set(capturedSessionId, geminiProcess); + } + + geminiProcess.sessionId = capturedSessionId; + + if (ws.setSessionId && typeof ws.setSessionId === 'function') { + ws.setSessionId(capturedSessionId); + } + + if (!sessionId && !sessionCreatedSent) { + sessionCreatedSent = true; + ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' })); + } + } + + const sess = sessionManager.getSession(capturedSessionId); + if (sess && !sess.cliSessionId) { + sess.cliSessionId = discoveredSessionId; + sessionManager.saveSession(capturedSessionId); } } }); @@ -292,30 +431,6 @@ async function spawnGemini(command, options = {}, ws) { const rawOutput = data.toString(); startTimeout(); // Re-arm the timeout - // For new sessions, create a session ID FIRST - if (!sessionId && !sessionCreatedSent && !capturedSessionId) { - capturedSessionId = `gemini_${Date.now()}`; - sessionCreatedSent = true; - - // Create session in session manager - sessionManager.createSession(capturedSessionId, cwd || process.cwd()); - - // Save the user message now that we have a session ID - if (command) { - sessionManager.addMessage(capturedSessionId, 'user', command); - } - - // Update process key with captured session ID - if (processKey !== capturedSessionId) { - activeGeminiProcesses.delete(processKey); - activeGeminiProcesses.set(capturedSessionId, geminiProcess); - } - - ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId); - - ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' })); - } - if (responseHandler) { responseHandler.processData(rawOutput); } else if (rawOutput) { @@ -381,12 +496,38 @@ async function spawnGemini(command, options = {}, ws) { notifyTerminalState({ code }); resolve(); } else { - // code 127 = shell "command not found" — check installation + const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; + + // code 127 = shell "command not found" - check installation if (code === 127) { const installed = await providerAuthService.isProviderInstalled('gemini'); if (!installed) { - const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; - ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' })); + terminalFailureReason = 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'; + ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); + } + } else if (code === 41) { + // Gemini CLI documents exit code 41 as FatalAuthenticationError. + // Surface an actionable auth error instead of a generic exit-code message. + let authErrorSuffix = ''; + try { + const authStatus = await providerAuthService.getProviderAuthStatus('gemini'); + if (!authStatus?.authenticated && authStatus?.error) { + authErrorSuffix = ` Details: ${authStatus.error}`; + } + } catch { + // Keep base remediation text when auth status lookup fails. + } + + terminalFailureReason = + 'Gemini authentication failed (exit code 41). ' + + 'Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.' + + authErrorSuffix; + ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); + } else { + const mappedError = mapGeminiExitCodeToMessage(code); + if (mappedError) { + terminalFailureReason = mappedError; + ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); } } @@ -394,7 +535,14 @@ async function spawnGemini(command, options = {}, ws) { code, error: code === null ? 'Gemini CLI process was terminated or timed out' : null }); - reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`)); + reject( + new Error( + terminalFailureReason + || (code === null + ? 'Gemini CLI process was terminated or timed out' + : `Gemini CLI exited with code ${code}`) + ) + ); } }); diff --git a/server/modules/database/migrations.ts b/server/modules/database/migrations.ts index 60debe52..5b0490cb 100644 --- a/server/modules/database/migrations.ts +++ b/server/modules/database/migrations.ts @@ -257,8 +257,10 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { if (!shouldRebuild) { addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT'); + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME'); + db.exec('UPDATE sessions SET isArchived = COALESCE(isArchived, 0)'); db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)'); db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)'); return; @@ -284,6 +286,10 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { ? 'jsonl_path' : 'NULL'; + const isArchivedExpression = columnNames.includes('isArchived') + ? 'COALESCE(isArchived, 0)' + : '0'; + const createdAtExpression = columnNames.includes('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; @@ -303,6 +309,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { custom_name TEXT, project_path TEXT, jsonl_path TEXT, + isArchived BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (session_id), @@ -319,6 +326,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { ${customNameExpression} AS custom_name, ${projectPathExpression} AS project_path, ${jsonlPathExpression} AS jsonl_path, + ${isArchivedExpression} AS isArchived, ${createdAtExpression} AS created_at, ${updatedAtExpression} AS updated_at, rowid AS source_rowid @@ -332,6 +340,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { custom_name, project_path, jsonl_path, + isArchived, created_at, updated_at, ROW_NUMBER() OVER ( @@ -346,6 +355,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { custom_name, project_path, jsonl_path, + isArchived, created_at, updated_at ) @@ -355,6 +365,7 @@ const rebuildSessionsTableWithProjectSchema = (db: Database): void => { custom_name, project_path, jsonl_path, + isArchived, created_at, updated_at FROM ranked_rows @@ -421,6 +432,7 @@ export const runMigrations = (db: Database) => { db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(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)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)'); diff --git a/server/modules/database/repositories/projects.db.ts b/server/modules/database/repositories/projects.db.ts index c99b8a54..ddbec8fa 100644 --- a/server/modules/database/repositories/projects.db.ts +++ b/server/modules/database/repositories/projects.db.ts @@ -95,6 +95,19 @@ export const projectsDb = { `).all() as ProjectRepositoryRow[]; }, + /** + * Archived rows are queried separately so archive-focused UIs can present + * hidden workspaces without reintroducing them into the active sidebar list. + */ + getArchivedProjectPaths(): ProjectRepositoryRow[] { + const db = getConnection(); + return db.prepare(` + SELECT project_id, project_path, custom_project_name, isStarred, isArchived + FROM projects + WHERE isArchived = 1 + `).all() as ProjectRepositoryRow[]; + }, + getCustomProjectName(projectPath: string): string | null { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); diff --git a/server/modules/database/repositories/sessions.db.integration.test.ts b/server/modules/database/repositories/sessions.db.integration.test.ts new file mode 100644 index 00000000..d14ec5ae --- /dev/null +++ b/server/modules/database/repositories/sessions.db.integration.test.ts @@ -0,0 +1,72 @@ +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-db-')); + 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('session archive queries hide archived rows from active project views', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createSession('session-active', 'claude', '/workspace/demo-project', 'Active Session'); + sessionsDb.createSession('session-archived', 'claude', '/workspace/demo-project', 'Archived Session'); + sessionsDb.updateSessionIsArchived('session-archived', true); + + const activeSessions = sessionsDb.getAllSessions(); + const archivedSessions = sessionsDb.getArchivedSessions(); + const activeProjectSessions = sessionsDb.getSessionsByProjectPath('/workspace/demo-project'); + const allProjectSessions = sessionsDb.getSessionsByProjectPathIncludingArchived('/workspace/demo-project'); + + assert.deepEqual(activeSessions.map((session) => session.session_id), ['session-active']); + assert.deepEqual(archivedSessions.map((session) => session.session_id), ['session-archived']); + assert.deepEqual(activeProjectSessions.map((session) => session.session_id), ['session-active']); + assert.deepEqual( + allProjectSessions.map((session) => session.session_id).sort(), + ['session-active', 'session-archived'], + ); + assert.equal(sessionsDb.countSessionsByProjectPath('/workspace/demo-project'), 1); + }); +}); + +test('createSession reactivates archived rows when the session becomes active again', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'First Name'); + sessionsDb.updateSessionIsArchived('session-reused', true); + + sessionsDb.createSession('session-reused', 'claude', '/workspace/demo-project', 'Updated Name'); + + const activeSessions = sessionsDb.getAllSessions(); + const archivedSessions = sessionsDb.getArchivedSessions(); + const restoredSession = sessionsDb.getSessionById('session-reused'); + + assert.equal(activeSessions.length, 1); + assert.equal(activeSessions[0]?.session_id, 'session-reused'); + assert.equal(activeSessions[0]?.custom_name, 'Updated Name'); + assert.equal(archivedSessions.length, 0); + assert.equal(restoredSession?.isArchived, 0); + }); +}); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 19a96a56..d79fdeb8 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -8,13 +8,14 @@ type SessionRow = { project_path: string | null; jsonl_path: string | null; custom_name: string | null; + isArchived: number; created_at: string; updated_at: string; }; type SessionMetadataLookupRow = Pick< SessionRow, - 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at' + 'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'isArchived' | 'created_at' | 'updated_at' >; function normalizeTimestamp(value?: string): string | null { @@ -53,13 +54,14 @@ export const sessionsDb = { projectsDb.createProjectPath(normalizedProjectPath); db.prepare( - `INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) + `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)) ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, 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, @@ -87,7 +89,7 @@ export const sessionsDb = { const db = getConnection(); const row = db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at FROM sessions WHERE session_id = ? ORDER BY updated_at DESC @@ -102,8 +104,25 @@ export const sessionsDb = { const db = getConnection(); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at - FROM sessions` + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + FROM sessions + WHERE isArchived = 0` + ) + .all() as SessionRow[]; + }, + + /** + * Archived rows are intentionally queried separately so the caller can render + * them in a dedicated view without reintroducing them into active session lists. + */ + getArchivedSessions(): SessionRow[] { + const db = getConnection(); + return db + .prepare( + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + FROM sessions + WHERE isArchived = 1 + ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ) .all() as SessionRow[]; }, @@ -113,7 +132,24 @@ export const sessionsDb = { const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at + FROM sessions + WHERE project_path = ? + AND isArchived = 0` + ) + .all(normalizedProjectPath) as SessionRow[]; + }, + + /** + * Permanent project deletion must see every session row for the path, + * including archived ones, so their transcript files can be cleaned up. + */ + getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] { + const db = getConnection(); + const normalizedProjectPath = normalizeProjectPath(projectPath); + return db + .prepare( + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at FROM sessions WHERE project_path = ?` ) @@ -125,9 +161,10 @@ export const sessionsDb = { const normalizedProjectPath = normalizeProjectPath(projectPath); return db .prepare( - `SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at + `SELECT session_id, provider, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at FROM sessions WHERE project_path = ? + AND isArchived = 0 ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC LIMIT ? OFFSET ?` ) @@ -141,7 +178,8 @@ export const sessionsDb = { .prepare( `SELECT COUNT(*) AS count FROM sessions - WHERE project_path = ?` + WHERE project_path = ? + AND isArchived = 0` ) .get(normalizedProjectPath) as { count: number } | undefined; @@ -167,6 +205,19 @@ export const sessionsDb = { return row?.custom_name ?? null; }, + /** + * Soft-delete and restore both use the same flag update so callers keep the + * row, metadata, and file path intact while toggling visibility. + */ + updateSessionIsArchived(sessionId: string, isArchived: boolean): void { + const db = getConnection(); + db.prepare( + `UPDATE sessions + SET isArchived = ? + WHERE session_id = ?` + ).run(isArchived ? 1 : 0, sessionId); + }, + deleteSessionById(sessionId: string): boolean { const db = getConnection(); return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0; diff --git a/server/modules/database/schema.ts b/server/modules/database/schema.ts index 7af3d80d..b3639af2 100644 --- a/server/modules/database/schema.ts +++ b/server/modules/database/schema.ts @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS sessions ( custom_name TEXT, project_path TEXT, jsonl_path TEXT, + isArchived BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (session_id), diff --git a/server/modules/projects/projects.routes.ts b/server/modules/projects/projects.routes.ts index a1c94352..5b52425c 100644 --- a/server/modules/projects/projects.routes.ts +++ b/server/modules/projects/projects.routes.ts @@ -3,9 +3,9 @@ import express from 'express'; import { createProject, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js'; import { startCloneProject } from '@/modules/projects/services/project-clone.service.js'; import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js'; -import { AppError, asyncHandler } from '@/shared/utils.js'; -import { getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js'; -import { deleteOrArchiveProject } from '@/modules/projects/services/project-delete.service.js'; +import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js'; +import { getArchivedProjectsWithSessions, getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js'; +import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js'; import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js'; const router = express.Router(); @@ -73,6 +73,14 @@ router.get( }), ); +router.get( + '/archived', + asyncHandler(async (_req, res) => { + const projects = await getArchivedProjectsWithSessions(); + res.json(createApiSuccessResponse({ projects })); + }), +); + router.get( '/:projectId/sessions', asyncHandler(async (req, res) => { @@ -230,6 +238,15 @@ router.post( }), ); +router.post( + '/:projectId/restore', + asyncHandler(async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : ''; + restoreArchivedProject(projectId); + res.json(createApiSuccessResponse({ projectId, isArchived: false })); + }), +); + /** * - `force` not set / false: archive project in DB only (`isArchived` = 1; hidden from active list). * - `force=true`: remove DB row, delete session rows for that path, remove all `*.jsonl` under the Claude project dir. diff --git a/server/modules/projects/services/project-delete.service.ts b/server/modules/projects/services/project-delete.service.ts index a743b4b6..cbb1c7ed 100644 --- a/server/modules/projects/services/project-delete.service.ts +++ b/server/modules/projects/services/project-delete.service.ts @@ -42,7 +42,7 @@ async function unlinkJsonlIfExists(filePath: string): Promise { * Loads all session rows for the project path and removes each distinct `jsonl_path` file on disk. */ export async function deleteSessionJsonlFilesForProjectPath(projectPath: string): Promise { - const sessions = sessionsDb.getSessionsByProjectPath(projectPath); + const sessions = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath); const paths = uniqueJsonlPathsFromSessions(sessions); for (const filePath of paths) { @@ -73,3 +73,18 @@ export async function deleteOrArchiveProject(projectId: string, force: boolean): sessionsDb.deleteSessionsByProjectPath(row.project_path); projectsDb.deleteProjectById(projectId); } + +/** + * Restores one archived project row back into the active project list. + */ +export function restoreArchivedProject(projectId: string): void { + const row = projectsDb.getProjectById(projectId); + if (!row) { + throw new AppError(`Unknown projectId: ${projectId}`, { + code: 'PROJECT_NOT_FOUND', + statusCode: 404, + }); + } + + projectsDb.updateProjectIsArchivedById(projectId, false); +} 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 4d473a21..55a5d6d8 100644 --- a/server/modules/projects/services/projects-with-sessions-fetch.service.ts +++ b/server/modules/projects/services/projects-with-sessions-fetch.service.ts @@ -40,6 +40,10 @@ export type ProjectListItem = { }; }; +export type ArchivedProjectListItem = ProjectListItem & { + isArchived: true; +}; + type ProgressUpdate = { phase: 'loading' | 'complete'; current: number; @@ -150,6 +154,16 @@ function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByPr return byProvider; } +function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult { + const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[]; + + return { + sessionsByProvider: bucketSessionRowsByProvider(rows), + total: rows.length, + hasMore: false, + }; +} + /** * Reads one paginated project session slice from the DB and groups rows by provider. */ @@ -255,6 +269,56 @@ export async function getProjectsWithSessions( return projects; } +/** + * Reads archived projects from DB and includes every session row for each + * project path, because an archived workspace should surface all preserved + * conversation history in the archive view regardless of each session's flag. + */ +export async function getArchivedProjectsWithSessions( + options: Pick = {}, +): Promise { + if (!options.skipSynchronization) { + await sessionSynchronizerService.synchronizeSessions(); + } + + const projectRows = projectsDb.getArchivedProjectPaths() as Array<{ + project_id: string; + project_path: string; + custom_project_name?: string | null; + isStarred?: number; + }>; + + const archivedProjects: ArchivedProjectListItem[] = []; + + for (const row of projectRows) { + const displayName = + row.custom_project_name && row.custom_project_name.trim().length > 0 + ? row.custom_project_name + : await generateDisplayName(path.basename(row.project_path) || row.project_path, row.project_path); + + const sessionsPage = readProjectSessionsIncludingArchived(row.project_path); + + archivedProjects.push({ + projectId: row.project_id, + path: row.project_path, + displayName, + fullPath: row.project_path, + isStarred: Boolean(row.isStarred), + isArchived: true, + sessions: sessionsPage.sessionsByProvider.claude, + cursorSessions: sessionsPage.sessionsByProvider.cursor, + codexSessions: sessionsPage.sessionsByProvider.codex, + geminiSessions: sessionsPage.sessionsByProvider.gemini, + sessionMeta: { + hasMore: sessionsPage.hasMore, + total: sessionsPage.total, + }, + }); + } + + return archivedProjects; +} + /** * Loads one paginated session slice for a specific project id. */ 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 66f055fd..1bf3bffc 100644 --- a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +++ b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts @@ -157,9 +157,14 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined; const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined; + const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined; - if ((eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim())) { - return aiTitle || lastPrompt; + if ( + (eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || + (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) || + (eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim()) + ) { + return aiTitle || lastPrompt || claudeRenamedTitle; } } } catch { diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index ffd358f3..f803d92c 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -200,17 +200,18 @@ async function getSessionMessages( } /** - * Claude writes internal command and system reminder entries into history. - * Those are useful for the CLI but should not appear in the user-facing chat. + * Claude writes a mix of truly internal transcript rows and "UI-hidden" local + * command artifacts into the same JSONL stream. + * + * Important distinction: + * - system reminders / caveats / interruption banners should stay hidden + * - local command payloads (`...`) and stdout wrappers + * (`...`) should be remapped into normal chat messages + * instead of being discarded as internal content */ const INTERNAL_CONTENT_PREFIXES = [ - '', - '', - '', - '', '', 'Caveat:', - 'This session is being continued from a previous', '[Request interrupted', ] as const; @@ -218,6 +219,73 @@ function isInternalContent(content: string): boolean { return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix)); } +/** + * Claude wraps local slash-command metadata in lightweight XML-like tags inside + * a plain string payload. We intentionally parse only the small tag surface we + * care about instead of introducing a generic XML parser for untrusted history. + */ +function extractTaggedContent(content: string, tagName: string): string | null { + const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content); + return match ? match[1] : null; +} + +type ClaudeLocalCommandPayload = { + commandName: string; + commandMessage: string; + commandArgs: string; +}; + +/** + * Converts Claude's hidden local command wrapper into structured metadata. + * + * The three tags often coexist in one string payload. Returning `null` lets the + * normal text path continue untouched for unrelated messages. + */ +function parseLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null { + const commandName = extractTaggedContent(content, 'command-name'); + const commandMessage = extractTaggedContent(content, 'command-message'); + const commandArgs = extractTaggedContent(content, 'command-args'); + + if (commandName === null && commandMessage === null && commandArgs === null) { + return null; + } + + return { + commandName: commandName ?? '', + commandMessage: commandMessage ?? '', + commandArgs: commandArgs ?? '', + }; +} + +/** + * Produces the short user-visible command string that should appear in chat. + * + * We prefer the slash-prefixed command name because that most closely matches + * what the user actually typed, and only fall back to the message body when the + * command name is unavailable in older transcript variants. + */ +function buildLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string { + const commandName = payload.commandName.trim(); + const commandMessage = payload.commandMessage.trim(); + const commandArgs = payload.commandArgs.trim(); + const baseCommand = commandName || commandMessage; + + if (!baseCommand) { + return ''; + } + + return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand; +} + +/** + * Claude local-command stdout may contain ANSI styling codes because it was + * captured from the terminal. The web chat should receive readable plain text. + */ +function stripAnsiFormatting(text: string): string { + return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, ''); +} + export class ClaudeSessionsProvider implements IProviderSessions { /** * Normalizes one Claude JSONL entry or live SDK stream event into the shared @@ -240,7 +308,7 @@ export class ClaudeSessionsProvider implements IProviderSessions { const ts = raw.timestamp || new Date().toISOString(); const baseId = raw.uuid || generateMessageId('claude'); - if (raw.message?.role === 'user' && raw.message?.content) { + if (raw.message?.role === 'user' && raw.message?.content && raw.isMeta !== true) { if (Array.isArray(raw.message.content)) { for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) { const part = raw.message.content[partIndex]; @@ -293,6 +361,80 @@ export class ClaudeSessionsProvider implements IProviderSessions { } } else if (typeof raw.message.content === 'string') { const text = raw.message.content; + + /** + * Claude stores compact summaries as synthetic "user" rows so the CLI + * can resume the next session turn with the summary in-context. + * + * For the web UI this is much more useful as assistant-authored summary + * text; otherwise it is both filtered by the generic internal-prefix + * check and visually mislabeled as a user message. + */ + if (raw.isCompactSummary === true && text.trim()) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: text, + isCompactSummary: true, + })); + return messages; + } + + /** + * Local slash commands are serialized as tagged text even though they + * are semantically a user action. Expose the parsed fields to the + * frontend and emit a plain user-visible command string so the command + * no longer disappears from history. + */ + const localCommandPayload = parseLocalCommandPayload(text); + if (localCommandPayload) { + const displayText = buildLocalCommandDisplayText(localCommandPayload); + if (displayText) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: displayText, + commandName: localCommandPayload.commandName, + commandMessage: localCommandPayload.commandMessage, + commandArgs: localCommandPayload.commandArgs, + isLocalCommand: true, + })); + } + return messages; + } + + /** + * Local command stdout is also written as a "user" row in Claude's + * transcript, but it is terminal output produced in response to the + * command. Re-label it as assistant text so the chat transcript matches + * the actual conversational flow seen by the user. + */ + const localCommandStdout = extractTaggedContent(text, 'local-command-stdout'); + if (localCommandStdout !== null) { + const stdoutText = stripAnsiFormatting(localCommandStdout).trim(); + if (stdoutText) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: stdoutText, + isLocalCommandStdout: true, + })); + } + return messages; + } + if (text && !isInternalContent(text)) { messages.push(createNormalizedMessage({ id: baseId, @@ -414,7 +556,9 @@ export class ClaudeSessionsProvider implements IProviderSessions { let result: ClaudeHistoryResult; try { - result = await getSessionMessages(sessionId, limit, offset); + // Load full history first so `total` reflects frontend-normalized messages, + // not raw JSONL records. + result = await getSessionMessages(sessionId, null, 0); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message); @@ -422,8 +566,6 @@ export class ClaudeSessionsProvider implements IProviderSessions { } const rawMessages = Array.isArray(result) ? result : (result.messages || []); - const total = Array.isArray(result) ? rawMessages.length : (result.total || 0); - const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore); const toolResultMap = new Map(); for (const raw of rawMessages) { @@ -464,12 +606,31 @@ export class ClaudeSessionsProvider implements IProviderSessions { } } + const totalNormalized = normalized.length; + let total = 0; + for (const msg of normalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } + 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; + return { - messages: normalized, + messages, total, hasMore, - offset, - limit, + offset: normalizedOffset, + limit: normalizedLimit, }; } } diff --git a/server/modules/providers/list/codex/codex-sessions.provider.ts b/server/modules/providers/list/codex/codex-sessions.provider.ts index a7fe8129..5cad1334 100644 --- a/server/modules/providers/list/codex/codex-sessions.provider.ts +++ b/server/modules/providers/list/codex/codex-sessions.provider.ts @@ -520,7 +520,9 @@ export class CodexSessionsProvider implements IProviderSessions { let result: CodexHistoryResult; try { - result = await getCodexSessionMessages(sessionId, limit, offset); + // Load full history first so `total` reflects frontend-normalized messages, + // not raw JSONL records. + result = await getCodexSessionMessages(sessionId, null, 0); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message); @@ -528,8 +530,6 @@ export class CodexSessionsProvider implements IProviderSessions { } const rawMessages = Array.isArray(result) ? result : (result.messages || []); - const total = Array.isArray(result) ? rawMessages.length : (result.total || 0); - const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore); const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage; const normalized: NormalizedMessage[] = []; @@ -552,12 +552,31 @@ export class CodexSessionsProvider implements IProviderSessions { } } + const totalNormalized = normalized.length; + let total = 0; + for (const msg of normalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } + 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; + return { - messages: normalized, + messages, total, hasMore, - offset, - limit, + offset: normalizedOffset, + limit: normalizedLimit, tokenUsage, }; } diff --git a/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts b/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts index 4be02dee..d5ea9b3c 100644 --- a/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +++ b/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts @@ -45,44 +45,28 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { */ async synchronize(since?: Date): Promise { const projectsDir = path.join(this.cursorHome, 'projects'); - const projectEntries = await listDirectoryEntriesSafe(projectsDir); - const seenProjectPaths = new Set(); let processed = 0; - for (const entry of projectEntries) { - if (!entry.isDirectory()) { + + const files = await findFilesRecursivelyCreatedAfter(projectsDir, '.jsonl', since ?? null); + + for (const filePath of files) { + const parsed = await this.processSessionFile(filePath); + if (!parsed) { continue; } - const workerLogPath = path.join(projectsDir, entry.name, 'worker.log'); - const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath); - if (!projectPath || seenProjectPaths.has(projectPath)) { - continue; - } - - seenProjectPaths.add(projectPath); - const projectHash = this.md5(projectPath); - const chatsDir = path.join(this.cursorHome, 'chats', projectHash); - const files = await findFilesRecursivelyCreatedAfter(chatsDir, '.jsonl', since ?? null); - - for (const filePath of files) { - const parsed = await this.processSessionFile(filePath); - if (!parsed) { - continue; - } - - const timestamps = await readFileTimestamps(filePath); - sessionsDb.createSession( - parsed.sessionId, - this.provider, - parsed.projectPath, - parsed.sessionName, - timestamps.createdAt, - timestamps.updatedAt, - filePath - ); - processed += 1; - } + const timestamps = await readFileTimestamps(filePath); + sessionsDb.createSession( + parsed.sessionId, + this.provider, + parsed.projectPath, + parsed.sessionName, + timestamps.createdAt, + timestamps.updatedAt, + filePath + ); + processed += 1; } return processed; @@ -113,13 +97,6 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { ); } - /** - * Produces the same project hash Cursor uses in chat directory names. - */ - private md5(input: string): string { - return crypto.createHash('md5').update(input).digest('hex'); - } - /** * Extracts project path from Cursor worker.log. */ @@ -149,7 +126,7 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { */ private async processSessionFile(filePath: string): Promise { const sessionId = path.basename(filePath, '.jsonl'); - const grandparentDir = path.dirname(path.dirname(filePath)); + const grandparentDir = path.dirname(path.dirname(path.dirname(filePath))); const workerLogPath = path.join(grandparentDir, 'worker.log'); const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath); diff --git a/server/modules/providers/list/cursor/cursor-sessions.provider.ts b/server/modules/providers/list/cursor/cursor-sessions.provider.ts index e276ba8c..90c9afa0 100644 --- a/server/modules/providers/list/cursor/cursor-sessions.provider.ts +++ b/server/modules/providers/list/cursor/cursor-sessions.provider.ts @@ -25,6 +25,167 @@ type CursorMessageBlob = { content: AnyRecord; }; +function isInternalCursorText(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + const normalized = value.trim(); + return normalized.startsWith('') || normalized.startsWith(''); +} + +function isInternalCursorPart(part: unknown): boolean { + if (!part || typeof part !== 'object') { + return false; + } + + const record = part as AnyRecord; + const type = typeof record.type === 'string' ? record.type : ''; + if (type === 'user_info' || type === 'system_reminder') { + return true; + } + + return isInternalCursorText(record.text); +} + +function unwrapUserQueryText(value: string, role: 'user' | 'assistant'): string { + if (role !== 'user') { + return value; + } + + const normalized = value.trimStart(); + const openTag = ''; + const closeTag = ''; + if (!normalized.startsWith(openTag)) { + return value; + } + + const afterOpen = normalized.slice(openTag.length); + const closeIndex = afterOpen.lastIndexOf(closeTag); + const inner = closeIndex >= 0 ? afterOpen.slice(0, closeIndex) : afterOpen; + return inner.trim(); +} + +function normalizeToolId(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function extractCursorToolResultContent(item: AnyRecord): string { + if (typeof item.result === 'string' && item.result.trim()) { + return item.result; + } + + if (typeof item.output === 'string' && item.output.trim()) { + return item.output; + } + + if (Array.isArray(item.experimental_content)) { + const experimentalText = item.experimental_content + .map((part: unknown) => { + if (typeof part === 'string') { + return part; + } + if (part && typeof part === 'object') { + const record = part as AnyRecord; + if (typeof record.text === 'string') { + return record.text; + } + } + return ''; + }) + .filter(Boolean) + .join('\n'); + + if (experimentalText.trim()) { + return experimentalText; + } + } + + return typeof item.result === 'string' ? item.result : ''; +} + +function parseCursorToolInput(rawInput: unknown): unknown { + if (typeof rawInput !== 'string') { + return rawInput; + } + + const trimmed = rawInput.trim(); + if (!trimmed) { + return rawInput; + } + + try { + return JSON.parse(trimmed); + } catch { + return rawInput; + } +} + +function normalizeCursorToolInput(toolName: string, rawInput: unknown): unknown { + const parsed = parseCursorToolInput(rawInput); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return parsed; + } + + const input = parsed as AnyRecord; + const normalized: AnyRecord = { ...input }; + + const filePath = input.file_path + ?? input.filePath + ?? input.path + ?? input.file + ?? input.filename; + if (typeof filePath === 'string' && filePath.trim()) { + normalized.file_path = filePath; + } + + if (toolName === 'Write') { + const content = input.content + ?? input.text + ?? input.value + ?? input.contents + ?? input.fileContent + ?? input.new_string + ?? input.newString; + if (typeof content === 'string') { + normalized.content = content; + } + } + + if (toolName === 'Edit') { + const oldString = input.old_string + ?? input.oldString + ?? input.old + ?? ''; + const newString = input.new_string + ?? input.newString + ?? input.new + ?? input.content + ?? ''; + + if (typeof oldString === 'string') { + normalized.old_string = oldString; + } + if (typeof newString === 'string') { + normalized.new_string = newString; + } + } + + if (toolName === 'ApplyPatch') { + const patch = input.patch ?? input.diff ?? input.content; + if (typeof patch === 'string' && !normalized.patch) { + normalized.patch = patch; + } + } + + return normalized; +} + function sanitizeCursorSessionId(sessionId: string): string { const normalized = sessionId.trim(); if (!normalized) { @@ -225,13 +386,14 @@ export class CursorSessionsProvider implements IProviderSessions { try { const blobs = await this.loadCursorBlobs(sessionId, projectPath); const allNormalized = this.normalizeCursorBlobs(blobs, sessionId); - const total = allNormalized.length; + const renderableMessages = allNormalized.filter((msg) => msg.kind !== 'tool_result'); + const total = renderableMessages.length; if (limit !== null) { const start = offset; const page = limit === 0 ? [] - : allNormalized.slice(start, start + limit); + : renderableMessages.slice(start, start + limit); const hasMore = limit === 0 ? start < total : start + limit < total; @@ -245,7 +407,7 @@ export class CursorSessionsProvider implements IProviderSessions { } return { - messages: allNormalized, + messages: renderableMessages, total, hasMore: false, offset: 0, @@ -283,11 +445,24 @@ export class CursorSessionsProvider implements IProviderSessions { let text = ''; if (Array.isArray(content.message.content)) { text = content.message.content - .map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '') + .map((part: string | AnyRecord) => { + if (typeof part === 'string') { + if (isInternalCursorText(part)) { + return ''; + } + return unwrapUserQueryText(part, role); + } + if (isInternalCursorPart(part)) { + return ''; + } + return unwrapUserQueryText(part?.text || '', role); + }) .filter(Boolean) .join('\n'); } else if (typeof content.message.content === 'string') { - text = content.message.content; + if (!isInternalCursorText(content.message.content)) { + text = unwrapUserQueryText(content.message.content, role); + } } if (text?.trim()) { messages.push(createNormalizedMessage({ @@ -316,7 +491,14 @@ export class CursorSessionsProvider implements IProviderSessions { if (item?.type !== 'tool-result') { continue; } - const toolCallId = item.toolCallId || content.id; + const cursorOptions = content.providerOptions?.cursor as AnyRecord | undefined; + const highLevelToolCallResult = cursorOptions?.highLevelToolCallResult; + const toolCallId = normalizeToolId(item.toolCallId) + || normalizeToolId(item.tool_call_id) + || normalizeToolId(highLevelToolCallResult?.toolCallId) + || normalizeToolId(highLevelToolCallResult?.tool_call_id) + || normalizeToolId(content.id) + || ''; messages.push(createNormalizedMessage({ id: `${baseId}_tr`, sessionId, @@ -324,8 +506,9 @@ export class CursorSessionsProvider implements IProviderSessions { provider: PROVIDER, kind: 'tool_result', toolId: toolCallId, - content: item.result || '', - isError: false, + content: extractCursorToolResultContent(item), + isError: Boolean(item.isError || item.is_error), + toolUseResult: highLevelToolCallResult, })); } continue; @@ -336,8 +519,15 @@ export class CursorSessionsProvider implements IProviderSessions { if (Array.isArray(content.content)) { for (let partIdx = 0; partIdx < content.content.length; partIdx++) { const part = content.content[partIdx]; + if (isInternalCursorPart(part)) { + continue; + } if (part?.type === 'text' && part?.text) { + const normalizedPartText = unwrapUserQueryText(part.text, role); + if (!normalizedPartText) { + continue; + } messages.push(createNormalizedMessage({ id: `${baseId}_${partIdx}`, sessionId, @@ -345,7 +535,7 @@ export class CursorSessionsProvider implements IProviderSessions { provider: PROVIDER, kind: 'text', role, - content: part.text, + content: normalizedPartText, sequence: blob.sequence, rowid: blob.rowid, })); @@ -361,7 +551,11 @@ export class CursorSessionsProvider implements IProviderSessions { } else if (part?.type === 'tool-call' || part?.type === 'tool_use') { const rawToolName = part.toolName || part.name || 'Unknown Tool'; const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName; - const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`; + const toolId = normalizeToolId(part.toolCallId) + || normalizeToolId(part.tool_call_id) + || normalizeToolId(part.id) + || `tool_${i}_${partIdx}`; + const normalizedToolInput = normalizeCursorToolInput(rawToolName, part.args ?? part.input); const message = createNormalizedMessage({ id: `${baseId}_${partIdx}`, sessionId, @@ -369,14 +563,22 @@ export class CursorSessionsProvider implements IProviderSessions { provider: PROVIDER, kind: 'tool_use', toolName, - toolInput: part.args || part.input, + toolInput: normalizedToolInput, toolId, }); messages.push(message); toolUseMap.set(toolId, message); } } - } else if (typeof content.content === 'string' && content.content.trim()) { + } else if ( + typeof content.content === 'string' + && content.content.trim() + && !isInternalCursorText(content.content) + ) { + const normalizedText = unwrapUserQueryText(content.content, role); + if (!normalizedText) { + continue; + } messages.push(createNormalizedMessage({ id: baseId, sessionId, @@ -384,7 +586,7 @@ export class CursorSessionsProvider implements IProviderSessions { provider: PROVIDER, kind: 'text', role, - content: content.content, + content: normalizedText, sequence: blob.sequence, rowid: blob.rowid, })); @@ -401,6 +603,7 @@ export class CursorSessionsProvider implements IProviderSessions { toolUse.toolResult = { content: msg.content, isError: msg.isError, + toolUseResult: msg.toolUseResult, }; } } diff --git a/server/modules/providers/list/gemini/gemini-auth.provider.ts b/server/modules/providers/list/gemini/gemini-auth.provider.ts index 60b0749e..c6897e74 100644 --- a/server/modules/providers/list/gemini/gemini-auth.provider.ts +++ b/server/modules/providers/list/gemini/gemini-auth.provider.ts @@ -15,7 +15,24 @@ type GeminiCredentialsStatus = { error?: string; }; +type GeminiAuthType = + | 'oauth-personal' + | 'gemini-api-key' + | 'vertex-ai' + | 'compute-default-credentials' + | 'gateway' + | 'cloud-shell' + | null; + export class GeminiProviderAuth implements IProviderAuth { + /** + * Gemini CLI can override its home root via GEMINI_CLI_HOME. + * Use the same resolution so status checks match runtime behavior. + */ + private getGeminiCliHome(): string { + return process.env.GEMINI_CLI_HOME?.trim() || os.homedir(); + } + /** * Checks whether the Gemini CLI is available on this host. */ @@ -58,6 +75,88 @@ export class GeminiProviderAuth implements IProviderAuth { }; } + /** + * Parses dotenv-style key/value pairs. + */ + private parseEnvFile(content: string): Record { + const parsed: Record = {}; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const normalizedLine = line.startsWith('export ') + ? line.slice('export '.length).trim() + : line; + const separatorIndex = normalizedLine.indexOf('='); + if (separatorIndex <= 0) { + continue; + } + + const key = normalizedLine.slice(0, separatorIndex).trim(); + if (!key) { + continue; + } + + let value = normalizedLine.slice(separatorIndex + 1).trim(); + const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')); + if (quoted) { + value = value.slice(1, -1); + } else { + value = value.replace(/\s+#.*$/, '').trim(); + } + + parsed[key] = value; + } + + return parsed; + } + + /** + * Loads user-level auth env in Gemini's "first file found" order. + */ + private async loadUserLevelAuthEnv(): Promise> { + const geminiCliHome = this.getGeminiCliHome(); + const envCandidates = [ + path.join(geminiCliHome, '.gemini', '.env'), + path.join(geminiCliHome, '.env'), + ]; + + for (const envPath of envCandidates) { + try { + const content = await readFile(envPath, 'utf8'); + return this.parseEnvFile(content); + } catch { + // Continue to the next fallback. + } + } + + return {}; + } + + /** + * Reads Gemini's selected auth type from settings.json when available. + */ + private async readSelectedAuthType(): Promise { + try { + const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json'); + const content = await readFile(settingsPath, 'utf8'); + const settings = readObjectRecord(JSON.parse(content)); + const security = readObjectRecord(settings?.security); + const auth = readObjectRecord(security?.auth); + const selectedType = readOptionalString(auth?.selectedType); + if (!selectedType) { + return null; + } + + return selectedType as GeminiAuthType; + } catch { + return null; + } + } + /** * Checks Gemini credentials from API key env vars or local OAuth credential files. */ @@ -66,8 +165,46 @@ export class GeminiProviderAuth implements IProviderAuth { return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; } + const userEnv = await this.loadUserLevelAuthEnv(); + if (readOptionalString(userEnv.GEMINI_API_KEY)) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + const selectedType = await this.readSelectedAuthType(); + if (selectedType === 'vertex-ai') { + const hasGoogleApiKey = Boolean( + process.env.GOOGLE_API_KEY?.trim() + || readOptionalString(userEnv.GOOGLE_API_KEY) + ); + const hasProject = Boolean( + process.env.GOOGLE_CLOUD_PROJECT?.trim() + || process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() + || readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT) + || readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID) + ); + const hasLocation = Boolean( + process.env.GOOGLE_CLOUD_LOCATION?.trim() + || readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION) + ); + const hasServiceAccount = Boolean( + process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim() + || readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS) + ); + + if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) { + return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' }; + } + + return { + authenticated: false, + email: null, + method: 'vertex_ai', + error: 'Gemini is set to Vertex AI, but required env vars are missing', + }; + } + try { - const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json'); const content = await readFile(credsPath, 'utf8'); const creds = readObjectRecord(JSON.parse(content)) ?? {}; const accessToken = readOptionalString(creds.access_token); @@ -106,6 +243,25 @@ export class GeminiProviderAuth implements IProviderAuth { method: 'credentials_file', }; } catch { + if (selectedType === 'gemini-api-key') { + return { + authenticated: false, + email: null, + method: 'api_key', + error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable', + }; + } + + if (selectedType === 'oauth-personal') { + return { + authenticated: false, + email: null, + method: 'credentials_file', + error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found', + }; + } + + // If no explicit auth type was selected, surface the generic "not configured" error. return { authenticated: false, email: null, @@ -140,7 +296,7 @@ export class GeminiProviderAuth implements IProviderAuth { */ private async getActiveAccountEmail(): Promise { try { - const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); + const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json'); const accContent = await readFile(accPath, 'utf8'); const accounts = readObjectRecord(JSON.parse(accContent)); return readOptionalString(accounts?.active) ?? null; diff --git a/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts b/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts index 52c62e9b..7ec3eff9 100644 --- a/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +++ b/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts @@ -39,33 +39,37 @@ export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer { async synchronize(since?: Date): Promise { const projectHashLookup = this.buildProjectHashLookup(); - const legacySessionFiles = await findFilesRecursivelyCreatedAfter( - path.join(this.geminiHome, 'sessions'), - '.json', - since ?? null - ); - const legacyTempFiles = await findFilesRecursivelyCreatedAfter( - path.join(this.geminiHome, 'tmp'), - '.json', - since ?? null - ); - const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter( - path.join(this.geminiHome, 'sessions'), - '.jsonl', - since ?? null - ); + // const legacySessionFiles = await findFilesRecursivelyCreatedAfter( + // path.join(this.geminiHome, 'sessions'), + // '.json', + // since ?? null + // ); + // Gemini creates overlapping artifacts across `sessions/` and `tmp/`. + // We currently index only `tmp/*/chats/*.jsonl` because those files are the + // live transcript source and avoid duplicate session rows from mirrored files. + // const legacyTempFiles = await findFilesRecursivelyCreatedAfter( + // path.join(this.geminiHome, 'tmp'), + // '.json', + // since ?? null + // ); + // const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter( + // path.join(this.geminiHome, 'sessions'), + // '.jsonl', + // since ?? null + // ); const jsonlTempFiles = await findFilesRecursivelyCreatedAfter( path.join(this.geminiHome, 'tmp'), '.jsonl', since ?? null ); - // Process legacy JSON first, then JSONL. If both exist for a session id, - // the JSONL artifact becomes the canonical jsonl_path via upsert. + // Current strategy: index only temp chat JSONL artifacts. const files = [ - ...legacySessionFiles, - ...legacyTempFiles, - ...jsonlSessionFiles, + // ...legacySessionFiles, + // Intentionally disabled to avoid duplicate indexing from mirrored + // `sessions/*.json` and `sessions/*.jsonl` artifacts. + // ...legacyTempFiles, + // ...jsonlSessionFiles, ...jsonlTempFiles, ]; diff --git a/server/modules/providers/list/gemini/gemini-sessions.provider.ts b/server/modules/providers/list/gemini/gemini-sessions.provider.ts index 606a1f17..98de12c7 100644 --- a/server/modules/providers/list/gemini/gemini-sessions.provider.ts +++ b/server/modules/providers/list/gemini/gemini-sessions.provider.ts @@ -528,10 +528,16 @@ export class GeminiSessionsProvider implements IProviderSessions { const messages = pageLimit === null ? normalized.slice(start) : normalized.slice(start, start + pageLimit); + let total = 0; + for (const msg of normalized) { + if (msg.kind !== 'tool_result') { + total += 1; + } + } return { messages, - total: normalized.length, + total, hasMore: pageLimit === null ? false : start + pageLimit < normalized.length, offset: start, limit: pageLimit, diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index af6d16d6..ea95f83d 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -311,12 +311,33 @@ router.post( ); // ----------------- Session routes ----------------- +router.get( + '/sessions/archived', + asyncHandler(async (_req: Request, res: Response) => { + const sessions = sessionsService.listArchivedSessions(); + res.json(createApiSuccessResponse({ sessions })); + }), +); + router.delete( '/sessions/:sessionId', asyncHandler(async (req: Request, res: Response) => { const sessionId = parseSessionId(req.params.sessionId); - const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? false; - const result = await sessionsService.deleteSessionById(sessionId, deletedFromDisk); + const force = parseOptionalBooleanQuery(req.query.force, 'force') ?? false; + const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? force; + const result = await sessionsService.deleteOrArchiveSessionById(sessionId, { + force, + deletedFromDisk, + }); + res.json(createApiSuccessResponse(result)); + }), +); + +router.post( + '/sessions/:sessionId/restore', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = parseSessionId(req.params.sessionId); + const result = sessionsService.restoreSessionById(sessionId); res.json(createApiSuccessResponse(result)); }), ); diff --git a/server/modules/providers/services/session-conversations-search.service.ts b/server/modules/providers/services/session-conversations-search.service.ts index afc8bdac..101a0955 100644 --- a/server/modules/providers/services/session-conversations-search.service.ts +++ b/server/modules/providers/services/session-conversations-search.service.ts @@ -89,13 +89,8 @@ const RIPGREP_CHUNK_CONCURRENCY = 6; const UNKNOWN_PROJECT_KEY = '__unknown_project__'; const INTERNAL_CONTENT_PREFIXES = [ - '', - '', - '', - '', '', 'Caveat:', - 'This session is being continued from a previous', 'Invalid API key', '[Request interrupted', ] as const; @@ -302,6 +297,135 @@ function extractClaudeText(content: unknown): string { .join(' '); } +function extractTaggedContent(content: string, tagName: string): string | null { + const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content); + return match ? match[1] : null; +} + +type ClaudeLocalCommandPayload = { + commandName: string; + commandMessage: string; + commandArgs: string; +}; + +function parseClaudeLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null { + const commandName = extractTaggedContent(content, 'command-name'); + const commandMessage = extractTaggedContent(content, 'command-message'); + const commandArgs = extractTaggedContent(content, 'command-args'); + + if (commandName === null && commandMessage === null && commandArgs === null) { + return null; + } + + return { + commandName: commandName ?? '', + commandMessage: commandMessage ?? '', + commandArgs: commandArgs ?? '', + }; +} + +function buildClaudeLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string { + const commandName = payload.commandName.trim(); + const commandMessage = payload.commandMessage.trim(); + const commandArgs = payload.commandArgs.trim(); + const baseCommand = commandName || commandMessage; + + if (!baseCommand) { + return ''; + } + + return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand; +} + +function stripAnsiFormatting(text: string): string { + return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, ''); +} + +type ClaudeSearchableMessage = { + text: string; + role: 'user' | 'assistant'; +}; + +/** + * Claude mixes visible chat, compact summaries, and local command wrappers into + * the same transcript stream. Search should operate on the user-visible meaning + * of those rows rather than the raw wrapper syntax. + */ +function extractClaudeSearchableMessage(entry: AnyRecord): ClaudeSearchableMessage | null { + if (!entry.message?.content || entry.isApiErrorMessage) { + return null; + } + + const rawRole = entry.message.role; + if (rawRole !== 'user' && rawRole !== 'assistant') { + return null; + } + + if (typeof entry.message.content === 'string') { + const content = String(entry.message.content); + + if (entry.isCompactSummary === true && content.trim()) { + return { + text: content, + role: 'assistant', + }; + } + + const localCommand = parseClaudeLocalCommandPayload(content); + if (localCommand) { + const displayText = buildClaudeLocalCommandDisplayText(localCommand); + return displayText + ? { + text: displayText, + role: 'user', + } + : null; + } + + const localCommandStdout = extractTaggedContent(content, 'local-command-stdout'); + if (localCommandStdout !== null) { + const stdoutText = stripAnsiFormatting(localCommandStdout).trim(); + return stdoutText + ? { + text: stdoutText, + role: 'assistant', + } + : null; + } + + if (!content || isInternalContent(content)) { + return null; + } + + return { + text: content, + role: rawRole, + }; + } + + const text = extractClaudeText(entry.message.content); + if (!text) { + return null; + } + + if (entry.isCompactSummary === true) { + return { + text, + role: 'assistant', + }; + } + + if (isInternalContent(text)) { + return null; + } + + return { + text, + role: rawRole, + }; +} + function extractCodexText(content: unknown): string { if (typeof content === 'string') { return content; @@ -348,6 +472,7 @@ function extractGeminiText(content: unknown): string { function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSessionRow[] { const normalizedRows: SearchableSessionRow[] = []; + const projectArchiveStateByPath = new Map(); for (const row of rows) { const provider = row.provider as SearchableProvider; @@ -365,6 +490,27 @@ function normalizeSearchableSessions(rows: SessionRepositoryRow[]): SearchableSe continue; } + /** + * Active session rows can still belong to an archived project because + * project archiving intentionally preserves the underlying session data. + * Global conversation search should follow the visible workspace model, + * which means excluding any session whose owning project is archived. + * + * Cache the archive lookup per normalized project path so one search pass + * does not re-query the same project row for every session in that folder. + */ + const normalizedProjectPath = typeof row.project_path === 'string' ? row.project_path.trim() : ''; + if (normalizedProjectPath) { + if (!projectArchiveStateByPath.has(normalizedProjectPath)) { + const projectRow = projectsDb.getProjectPath(normalizedProjectPath); + projectArchiveStateByPath.set(normalizedProjectPath, Boolean(projectRow?.isArchived)); + } + + if (projectArchiveStateByPath.get(normalizedProjectPath) === true) { + continue; + } + } + normalizedRows.push({ ...row, provider, @@ -733,18 +879,21 @@ async function parseClaudeSessionMatches( } } - if (!entry.message?.content || entry.isApiErrorMessage) { + const searchableMessage = extractClaudeSearchableMessage(entry); + if (!searchableMessage) { continue; } - const role = entry.message.role; - if (role !== 'user' && role !== 'assistant') { - continue; - } + const { text, role } = searchableMessage; - const text = extractClaudeText(entry.message.content); - if (!text || isInternalContent(text)) { - continue; + /** + * Claude compact summaries are the most faithful session-summary source + * after a `/compact` because they describe the post-compaction state that + * the resumed session actually continues from. Prefer them over generic + * fallback user text when present. + */ + if (entry.isCompactSummary === true) { + state.resolvedSummary = text; } if (role === 'user') { diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 3a7348ed..7a36e599 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -18,16 +18,18 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = }, { provider: 'cursor', - rootPath: path.join(os.homedir(), '.cursor', 'chats'), + rootPath: path.join(os.homedir(), '.cursor', 'projects'), }, { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions'), }, - { - provider: 'gemini', - rootPath: path.join(os.homedir(), '.gemini', 'sessions'), - }, + // { + // provider: 'gemini', + // rootPath: path.join(os.homedir(), '.gemini', 'sessions'), + // }, + // Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there, + // which causes duplicate synchronization events. { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'tmp'), diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 32572e95..49b5dcb7 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -1,6 +1,7 @@ import fsp from 'node:fs/promises'; +import path from 'node:path'; -import { sessionsDb } from '@/modules/database/index.js'; +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js'; import type { FetchHistoryOptions, @@ -10,6 +11,19 @@ import type { } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; +type ArchivedSessionListItem = { + sessionId: string; + provider: LLMProvider; + projectId: string | null; + projectPath: string | null; + projectDisplayName: string; + sessionTitle: string; + createdAt: string | null; + updatedAt: string | null; + lastActivity: string | null; + isProjectArchived: boolean; +}; + /** * Removes one file if it exists. */ @@ -26,6 +40,28 @@ async function removeFileIfExists(filePath: string): Promise { } } +/** + * Archive rows need a stable project label even when the owning project is not + * part of the active sidebar payload. This lightweight resolver keeps the + * archive API self-contained while still matching the project's stored display + * name when one exists. + */ +function resolveProjectDisplayName( + projectPath: string | null, + customProjectName: string | null | undefined, +): string { + const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : ''; + if (trimmedCustomName.length > 0) { + return trimmedCustomName; + } + + if (!projectPath) { + return 'Unknown Project'; + } + + return path.basename(projectPath) || projectPath; +} + /** * Application service for provider-backed session message operations. * @@ -79,15 +115,53 @@ export const sessionsService = { }, /** - * Deletes one persisted session row by id. - * - * When `deletedFromDisk` is true and a session `jsonl_path` exists, the path - * is deleted from disk before the DB row is removed. + * Returns archived sessions with enough project metadata for the sidebar to + * group, filter, open, and restore them without a per-row follow-up query. */ - async deleteSessionById( + listArchivedSessions(): ArchivedSessionListItem[] { + const archivedSessions = sessionsDb.getArchivedSessions(); + const projectCache = new Map>(); + + return archivedSessions.map((session) => { + const projectPath = session.project_path?.trim() ? session.project_path : null; + let project = null; + + if (projectPath) { + if (!projectCache.has(projectPath)) { + projectCache.set(projectPath, projectsDb.getProjectPath(projectPath)); + } + project = projectCache.get(projectPath) ?? null; + } + + return { + sessionId: session.session_id, + provider: session.provider as LLMProvider, + projectId: project?.project_id ?? null, + projectPath, + projectDisplayName: resolveProjectDisplayName(projectPath, project?.custom_project_name), + sessionTitle: session.custom_name?.trim() || session.session_id, + createdAt: session.created_at ?? null, + updatedAt: session.updated_at ?? null, + lastActivity: session.updated_at ?? session.created_at ?? null, + isProjectArchived: Boolean(project?.isArchived), + }; + }); + }, + + /** + * Archives or permanently deletes one persisted session row by id. + * + * Soft-delete mirrors the project behavior by toggling `isArchived` so the + * row disappears from active lists but remains restorable. Force-delete + * optionally removes the transcript file before deleting the database row. + */ + async deleteOrArchiveSessionById( sessionId: string, - deletedFromDisk = false, - ): Promise<{ sessionId: string; deletedFromDisk: boolean }> { + options: { + force?: boolean; + deletedFromDisk?: boolean; + } = {}, + ): Promise<{ sessionId: string; action: 'archived' | 'deleted'; deletedFromDisk: boolean }> { const session = sessionsDb.getSessionById(sessionId); if (!session) { throw new AppError(`Session "${sessionId}" was not found.`, { @@ -96,8 +170,17 @@ export const sessionsService = { }); } + if (!options.force) { + sessionsDb.updateSessionIsArchived(sessionId, true); + return { + sessionId, + action: 'archived', + deletedFromDisk: false, + }; + } + let removedFromDisk = false; - if (deletedFromDisk && session.jsonl_path) { + if (options.deletedFromDisk && session.jsonl_path) { removedFromDisk = await removeFileIfExists(session.jsonl_path); } @@ -109,7 +192,27 @@ export const sessionsService = { }); } - return { sessionId, deletedFromDisk: removedFromDisk }; + return { + sessionId, + action: 'deleted', + deletedFromDisk: removedFromDisk, + }; + }, + + /** + * Restores one archived session back into the active sidebar lists. + */ + restoreSessionById(sessionId: string): { sessionId: string; isArchived: false } { + const session = sessionsDb.getSessionById(sessionId); + if (!session) { + throw new AppError(`Session "${sessionId}" was not found.`, { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + + sessionsDb.updateSessionIsArchived(sessionId, false); + return { sessionId, isArchived: false }; }, /** diff --git a/server/openai-codex.js b/server/openai-codex.js index 5a7a9007..03497c30 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -143,7 +143,7 @@ function transformCodexEvent(event) { case 'thread.started': return { type: 'thread_started', - threadId: event.id + threadId: event.thread_id || event.id }; case 'error': @@ -207,7 +207,8 @@ export async function queryCodex(command, options = {}, ws) { let codex; let thread; - let currentSessionId = sessionId; + let capturedSessionId = sessionId; + let sessionCreatedSent = false; let terminalFailure = null; const abortController = new AbortController(); @@ -231,20 +232,23 @@ export async function queryCodex(command, options = {}, ws) { thread = codex.startThread(threadOptions); } - // Get the thread ID - currentSessionId = thread.id || sessionId || `codex-${Date.now()}`; + const registerSession = (id) => { + if (!id) { + return; + } + activeCodexSessions.set(id, { + thread, + codex, + status: 'running', + abortController, + startedAt: new Date().toISOString() + }); + }; - // Track the session - activeCodexSessions.set(currentSessionId, { - thread, - codex, - status: 'running', - abortController, - startedAt: new Date().toISOString() - }); - - // Send session created event - sendMessage(ws, createNormalizedMessage({ kind: 'session_created', newSessionId: currentSessionId, sessionId: currentSessionId, provider: 'codex' })); + // Existing sessions can be tracked immediately; new sessions are tracked after thread.started. + if (capturedSessionId) { + registerSession(capturedSessionId); + } // Execute with streaming const streamedTurn = await thread.runStreamed(command, { @@ -252,11 +256,34 @@ export async function queryCodex(command, options = {}, ws) { }); for await (const event of streamedTurn.events) { + // Capture thread/session id lazily from the stream (Codex emits this asynchronously). + if (event.type === 'thread.started') { + const discoveredSessionId = event.thread_id || event.id || null; + if (discoveredSessionId && !capturedSessionId) { + capturedSessionId = discoveredSessionId; + registerSession(capturedSessionId); + + if (ws.setSessionId && typeof ws.setSessionId === 'function') { + ws.setSessionId(capturedSessionId); + } + + if (!sessionId && !sessionCreatedSent) { + sessionCreatedSent = true; + sendMessage(ws, createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'codex' })); + } + } + } + // Check if session was aborted - const session = activeCodexSessions.get(currentSessionId); - if (!session || session.status === 'aborted') { + if (abortController.signal.aborted) { break; } + if (capturedSessionId) { + const session = activeCodexSessions.get(capturedSessionId); + if (session?.status === 'aborted') { + break; + } + } if (event.type === 'item.started' || event.type === 'item.updated') { continue; @@ -265,7 +292,7 @@ export async function queryCodex(command, options = {}, ws) { const transformed = transformCodexEvent(event); // Normalize the transformed event into NormalizedMessage(s) via adapter - const normalizedMsgs = sessionsService.normalizeMessage('codex', transformed, currentSessionId); + const normalizedMsgs = sessionsService.normalizeMessage('codex', transformed, capturedSessionId || sessionId || null); for (const msg of normalizedMsgs) { sendMessage(ws, msg); } @@ -275,7 +302,7 @@ export async function queryCodex(command, options = {}, ws) { notifyRunFailed({ userId: ws?.userId || null, provider: 'codex', - sessionId: currentSessionId, + sessionId: capturedSessionId || sessionId || null, sessionName: sessionSummary, error: terminalFailure }); @@ -284,24 +311,29 @@ export async function queryCodex(command, options = {}, ws) { // Extract and send token usage if available (normalized to match Claude format) if (event.type === 'turn.completed' && event.usage) { const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0); - sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' })); + sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: capturedSessionId || sessionId || null, provider: 'codex' })); } } // Send completion event if (!terminalFailure) { - sendMessage(ws, createNormalizedMessage({ kind: 'complete', actualSessionId: thread.id, sessionId: currentSessionId, provider: 'codex' })); + sendMessage(ws, createNormalizedMessage({ + kind: 'complete', + actualSessionId: capturedSessionId || thread.id || sessionId || null, + sessionId: capturedSessionId || sessionId || null, + provider: 'codex' + })); notifyRunStopped({ userId: ws?.userId || null, provider: 'codex', - sessionId: currentSessionId, + sessionId: capturedSessionId || sessionId || null, sessionName: sessionSummary, stopReason: 'completed' }); } } catch (error) { - const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null; + const session = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null; const wasAborted = session?.status === 'aborted' || error?.name === 'AbortError' || @@ -316,12 +348,12 @@ export async function queryCodex(command, options = {}, ws) { ? 'Codex CLI is not configured. Please set up authentication first.' : error.message; - sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' })); + sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' })); if (!terminalFailure) { notifyRunFailed({ userId: ws?.userId || null, provider: 'codex', - sessionId: currentSessionId, + sessionId: capturedSessionId || sessionId || null, sessionName: sessionSummary, error }); @@ -330,8 +362,8 @@ export async function queryCodex(command, options = {}, ws) { } finally { // Update session status - if (currentSessionId) { - const session = activeCodexSessions.get(currentSessionId); + if (capturedSessionId) { + const session = activeCodexSessions.get(capturedSessionId); if (session) { session.status = session.status === 'aborted' ? 'aborted' : 'completed'; } diff --git a/server/shared/types.ts b/server/shared/types.ts index d15f69e7..af09abf2 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -102,6 +102,21 @@ export type NormalizedMessage = { kind: MessageKind; role?: 'user' | 'assistant'; content?: string; + /** + * Optional display-oriented metadata used by providers that need to expose + * richer transcript artifacts without introducing a brand-new message kind. + * + * Current Claude usage: + * - local slash commands expose parsed command fields + * - compact summaries are flagged so the UI can treat them differently later + */ + displayText?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; images?: unknown; toolName?: string; toolInput?: unknown; diff --git a/shared/modelConstants.js b/shared/modelConstants.js index 90b973ed..ae42ed27 100644 --- a/shared/modelConstants.js +++ b/shared/modelConstants.js @@ -84,6 +84,7 @@ export const GEMINI_MODELS = { { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, { value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, + { value: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, { value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" }, { diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 4dd6979a..1ba41b95 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -34,7 +34,6 @@ function AppContentInner() { markSessionAsInactive, markSessionAsProcessing, markSessionAsNotProcessing, - replaceTemporarySession, } = useSessionProtection(); const { @@ -191,7 +190,6 @@ function AppContentInner() { onSessionProcessing={markSessionAsProcessing} onSessionNotProcessing={markSessionAsNotProcessing} processingSessions={processingSessions} - onReplaceTemporarySession={replaceTemporarySession} onNavigateToSession={(targetSessionId: string, options) => navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) }) } diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index c53cd01d..000cd33f 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -10,6 +10,7 @@ import type { TouchEvent, } from 'react'; import { useDropzone } from 'react-dropzone'; + import { authenticatedFetch } from '../../../utils/api'; import { thinkingModes } from '../constants/thinkingModes'; import { grantClaudeToolPermission } from '../utils/chatPermissions'; @@ -21,6 +22,7 @@ import type { } from '../types/types'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import { escapeRegExp } from '../utils/chatFormatting'; + import { useFileMentions } from './useFileMentions'; import { type SlashCommand, useSlashCommands } from './useSlashCommands'; @@ -80,9 +82,6 @@ const createFakeSubmitEvent = () => { return { preventDefault: () => undefined } as unknown as FormEvent; }; -const isTemporarySessionId = (sessionId: string | null | undefined) => - Boolean(sessionId && sessionId.startsWith('new-session-')); - const getNotificationSessionSummary = ( selectedSession: ProjectSession | null, fallbackInput: string, @@ -533,7 +532,6 @@ export function useChatComposerState({ const effectiveSessionId = currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); - const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; const userMessage: ChatMessage = { type: 'user', @@ -559,10 +557,12 @@ export function useChatComposerState({ // Reset stale pending IDs from previous interrupted runs before creating a new one. sessionStorage.removeItem('pendingSessionId'); } + // For new sessions we intentionally keep this as `null` until the backend + // emits `session_created` with the canonical provider session id. pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; } - onSessionActive?.(sessionToActivate); - if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) { + if (effectiveSessionId) { + onSessionActive?.(effectiveSessionId); onSessionProcessing?.(effectiveSessionId); } @@ -868,7 +868,7 @@ export function useChatComposerState({ ]; const targetSessionId = - candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null; + candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null; if (!targetSessionId) { console.warn('Abort requested but no concrete session ID is available yet.'); diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 8f417de5..1590c4af 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -11,8 +11,9 @@ import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } * Convert NormalizedMessage[] from the session store into ChatMessage[] * that the existing UI components expect. * - * Internal/system content (e.g. , ) is already - * filtered server-side by the Claude provider module. + * Truly internal/system content is already filtered server-side. Some Claude + * transcript artifacts such as local slash commands and compact summaries are + * intentionally preserved and annotated so they can render like normal chat. */ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] { const converted: ChatMessage[] = []; @@ -26,6 +27,16 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes } for (const msg of messages) { + const sharedMetadata = { + displayText: msg.displayText, + commandName: msg.commandName, + commandMessage: msg.commandMessage, + commandArgs: msg.commandArgs, + isLocalCommand: msg.isLocalCommand, + isLocalCommandStdout: msg.isLocalCommandStdout, + isCompactSummary: msg.isCompactSummary, + }; + switch (msg.kind) { case 'text': { const content = msg.content || ''; @@ -42,12 +53,14 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes timestamp: msg.timestamp, isTaskNotification: true, taskStatus: taskNotifMatch[1]?.trim() || 'completed', + ...sharedMetadata, }); } else { converted.push({ type: 'user', content: unescapeWithMathProtection(decodeHtmlEntities(content)), timestamp: msg.timestamp, + ...sharedMetadata, }); } } else { @@ -58,6 +71,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes type: 'assistant', content: text, timestamp: msg.timestamp, + ...sharedMetadata, }); } break; @@ -106,6 +120,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes isComplete: Boolean(toolResult), } : undefined, + ...sharedMetadata, }); break; } @@ -117,6 +132,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: unescapeWithMathProtection(msg.content), timestamp: msg.timestamp, isThinking: true, + ...sharedMetadata, }); } break; @@ -126,6 +142,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes type: 'error', content: msg.content || 'Unknown error', timestamp: msg.timestamp, + ...sharedMetadata, }); break; @@ -135,6 +152,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: msg.content || '', timestamp: msg.timestamp, isInteractivePrompt: true, + ...sharedMetadata, }); break; @@ -145,6 +163,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes timestamp: msg.timestamp, isTaskNotification: true, taskStatus: msg.status || 'completed', + ...sharedMetadata, }); break; @@ -155,6 +174,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: msg.content, timestamp: msg.timestamp, isStreaming: true, + ...sharedMetadata, }); } break; diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 342ea117..86c85469 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -3,7 +3,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types'; -import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; +import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; type PendingViewSession = { @@ -51,7 +51,6 @@ type LatestChatMessage = { interface UseChatRealtimeHandlersArgs { latestMessage: LatestChatMessage | null; provider: LLMProvider; - selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; setCurrentSessionId: (sessionId: string | null) => void; @@ -61,13 +60,11 @@ interface UseChatRealtimeHandlersArgs { setTokenBudget: (budget: Record | null) => void; setPendingPermissionRequests: Dispatch>; pendingViewSessionRef: MutableRefObject; - streamBufferRef: MutableRefObject; streamTimerRef: MutableRefObject; accumulatedStreamRef: MutableRefObject; onSessionInactive?: (sessionId?: string | null) => void; onSessionProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void; - onReplaceTemporarySession?: (sessionId?: string | null) => void; onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void; onWebSocketReconnect?: () => void; sessionStore: SessionStore; @@ -80,7 +77,6 @@ interface UseChatRealtimeHandlersArgs { export function useChatRealtimeHandlers({ latestMessage, provider, - selectedProject, selectedSession, currentSessionId, setCurrentSessionId, @@ -90,13 +86,11 @@ export function useChatRealtimeHandlers({ setTokenBudget, setPendingPermissionRequests, pendingViewSessionRef, - streamBufferRef, streamTimerRef, accumulatedStreamRef, onSessionInactive, onSessionProcessing, onSessionNotProcessing, - onReplaceTemporarySession, onNavigateToSession, onWebSocketReconnect, sessionStore, @@ -187,7 +181,6 @@ export function useChatRealtimeHandlers({ if (msg.kind === 'stream_delta') { const text = msg.content || ''; if (!text) return; - streamBufferRef.current += text; accumulatedStreamRef.current += text; if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { @@ -216,12 +209,18 @@ export function useChatRealtimeHandlers({ sessionStore.finalizeStreaming(sid); } accumulatedStreamRef.current = ''; - streamBufferRef.current = ''; return; } // --- All other messages: route to store --- - if (sid) { + 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); } @@ -231,13 +230,16 @@ export function useChatRealtimeHandlers({ const newSessionId = msg.newSessionId; if (!newSessionId) break; - if (!currentSessionId || currentSessionId.startsWith('new-session-')) { + // We no longer synthesize client-side placeholder IDs. Until the provider + // announces `session_created`, the active id is expected to be null. + if (!currentSessionId) { + console.log('Session created with ID:', newSessionId); + console.log('Existing session ID:', currentSessionId); sessionStorage.setItem('pendingSessionId', newSessionId); if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { pendingViewSessionRef.current.sessionId = newSessionId; } setCurrentSessionId(newSessionId); - onReplaceTemporarySession?.(newSessionId); setPendingPermissionRequests((prev) => prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })), ); @@ -257,7 +259,6 @@ export function useChatRealtimeHandlers({ sessionStore.finalizeStreaming(sid); } accumulatedStreamRef.current = ''; - streamBufferRef.current = ''; setIsLoading(false); setCanAbortSession(false); @@ -386,7 +387,6 @@ export function useChatRealtimeHandlers({ }, [ latestMessage, provider, - selectedProject, selectedSession, currentSessionId, setCurrentSessionId, @@ -396,13 +396,11 @@ export function useChatRealtimeHandlers({ setTokenBudget, setPendingPermissionRequests, pendingViewSessionRef, - streamBufferRef, streamTimerRef, accumulatedStreamRef, onSessionInactive, onSessionProcessing, onSessionNotProcessing, - onReplaceTemporarySession, onNavigateToSession, onWebSocketReconnect, sessionStore, diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 6bff4a88..95278ffd 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -182,6 +182,7 @@ export function useChatSessionState({ messagesOffsetRef.current = 0; setHasMoreMessages(false); setTotalMessages(0); + setTokenBudget(null); setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); setAllMessagesLoaded(false); @@ -318,7 +319,6 @@ export function useChatSessionState({ if (!hasMoreMessages || !selectedSession || !selectedProject) return false; const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider === 'cursor') return false; isLoadingMoreRef.current = true; const previousScrollHeight = container.scrollHeight; @@ -551,7 +551,6 @@ export function useChatSessionState({ const scrollToTarget = async () => { if (!allMessagesLoadedRef.current && selectedSession && selectedProject) { const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider !== 'cursor') { try { // Load all messages into the store for search navigation const slot = await sessionStore.fetchFromServer(selectedSession.id, { @@ -573,7 +572,6 @@ export function useChatSessionState({ } catch { // Fall through and scroll in current messages } - } } setVisibleMessageCount(Infinity); @@ -628,7 +626,7 @@ export function useChatSessionState({ // Token usage fetch for Claude useEffect(() => { - if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { + if (!selectedProject || !selectedSession?.id) { setTokenBudget(null); return; } @@ -721,15 +719,6 @@ export function useChatSessionState({ if (!selectedSession || !selectedProject) return; if (isLoadingAllMessages) return; const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider === 'cursor') { - setVisibleMessageCount(Infinity); - setAllMessagesLoaded(true); - allMessagesLoadedRef.current = true; - setLoadAllJustFinished(true); - if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); - loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000); - return; - } const requestSessionId = selectedSession.id; allMessagesLoadedRef.current = true; diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 81bd5a5b..474f23e1 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -28,6 +28,7 @@ export interface SubagentChildTool { export interface ChatMessage { type: string; content?: string; + displayText?: string; timestamp: string | number | Date; images?: ChatImage[]; reasoning?: string; @@ -40,6 +41,12 @@ export interface ChatMessage { toolResult?: ToolResult | null; toolId?: string; toolCallId?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; isSubagentContainer?: boolean; subagentState?: { childTools: SubagentChildTool[]; @@ -108,7 +115,6 @@ export interface ChatInterfaceProps { onSessionProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void; processingSessions?: Set; - onReplaceTemporarySession?: (sessionId?: string | null) => void; onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void; onShowSettings?: () => void; autoExpandTools?: boolean; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 8589f29a..2bff948d 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -34,7 +34,6 @@ function ChatInterface({ onSessionProcessing, onSessionNotProcessing, processingSessions, - onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, @@ -50,7 +49,6 @@ function ChatInterface({ const { t } = useTranslation('chat'); const sessionStore = useSessionStore(); - const streamBufferRef = useRef(''); const streamTimerRef = useRef(null); const accumulatedStreamRef = useRef(''); const pendingViewSessionRef = useRef(null); @@ -60,7 +58,6 @@ function ChatInterface({ clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } - streamBufferRef.current = ''; accumulatedStreamRef.current = ''; }, []); @@ -225,7 +222,6 @@ function ChatInterface({ useChatRealtimeHandlers({ latestMessage, provider, - selectedProject, selectedSession, currentSessionId, setCurrentSessionId, @@ -235,13 +231,11 @@ function ChatInterface({ setTokenBudget, setPendingPermissionRequests, pendingViewSessionRef, - streamBufferRef, streamTimerRef, accumulatedStreamRef, onSessionInactive, onSessionProcessing, onSessionNotProcessing, - onReplaceTemporarySession, onNavigateToSession, onWebSocketReconnect: handleWebSocketReconnect, sessionStore, diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 9d47a7c2..0129195a 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -213,13 +213,6 @@ export default function ChatMessagesPane({ )} - {/* Performance warning when all messages are loaded */} - {allMessagesLoaded && ( -
- {t('session.messages.perfWarning')} -
- )} - {/* Legacy message count indicator (for non-paginated view) */} {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index d090852d..68b03b29 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -51,7 +51,6 @@ export type MainContentProps = { onSessionProcessing: SessionLifecycleHandler; onSessionNotProcessing: SessionLifecycleHandler; processingSessions: Set; - onReplaceTemporarySession: SessionLifecycleHandler; onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void; onShowSettings: () => void; externalMessageUpdate: number; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 1a9c7349..f0a29a70 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -47,7 +47,6 @@ function MainContent({ onSessionProcessing, onSessionNotProcessing, processingSessions, - onReplaceTemporarySession, onNavigateToSession, onShowSettings, externalMessageUpdate, @@ -137,7 +136,6 @@ function MainContent({ onSessionProcessing={onSessionProcessing} onSessionNotProcessing={onSessionNotProcessing} processingSessions={processingSessions} - onReplaceTemporarySession={onReplaceTemporarySession} onNavigateToSession={onNavigateToSession} onShowSettings={onShowSettings} autoExpandTools={autoExpandTools} diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index d950bc43..ba559442 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -5,8 +5,11 @@ import { api } from '../../../utils/api'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { + ArchivedProjectListItem, + ArchivedSessionListItem, DeleteProjectConfirmation, ProjectSortOrder, + SidebarSearchMode, SessionDeleteConfirmation, SessionWithProvider, } from '../types/types'; @@ -60,6 +63,20 @@ export type SearchProgress = { totalProjects: number; }; +type ArchivedSessionsApiPayload = { + success?: boolean; + data?: { + sessions?: ArchivedSessionListItem[]; + }; +}; + +type ArchivedProjectsApiPayload = { + success?: boolean; + data?: { + projects?: ArchivedProjectListItem[]; + }; +}; + type UseSidebarControllerArgs = { projects: Project[]; selectedProject: Project | null; @@ -112,10 +129,13 @@ export function useSidebarController({ const [deleteConfirmation, setDeleteConfirmation] = useState(null); const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); const [showVersionModal, setShowVersionModal] = useState(false); - const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects'); + const [searchMode, setSearchMode] = useState('projects'); const [conversationResults, setConversationResults] = useState(null); const [isSearching, setIsSearching] = useState(false); const [searchProgress, setSearchProgress] = useState(null); + const [archivedProjects, setArchivedProjects] = useState([]); + const [archivedSessions, setArchivedSessions] = useState([]); + const [isArchivedSessionsLoading, setIsArchivedSessionsLoading] = useState(false); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState>(new Map()); const [loadingMoreProjects, setLoadingMoreProjects] = useState>(new Set()); @@ -201,6 +221,40 @@ export function useSidebarController({ onRefreshRef.current = onRefresh; }, [onRefresh]); + const fetchArchivedSessions = useCallback(async () => { + setIsArchivedSessionsLoading(true); + + try { + const [archivedProjectsResponse, archivedSessionsResponse] = await Promise.all([ + api.archivedProjects(), + api.getArchivedSessions(), + ]); + + if (!archivedProjectsResponse.ok) { + throw new Error(`Failed to load archived projects: ${archivedProjectsResponse.status}`); + } + + if (!archivedSessionsResponse.ok) { + throw new Error(`Failed to load archived sessions: ${archivedSessionsResponse.status}`); + } + + const archivedProjectsPayload = (await archivedProjectsResponse.json()) as ArchivedProjectsApiPayload; + const archivedSessionsPayload = (await archivedSessionsResponse.json()) as ArchivedSessionsApiPayload; + const nextProjects = Array.isArray(archivedProjectsPayload.data?.projects) ? archivedProjectsPayload.data.projects : []; + const archivedProjectIds = new Set(nextProjects.map((project) => project.projectId)); + const nextStandaloneSessions = Array.isArray(archivedSessionsPayload.data?.sessions) + ? archivedSessionsPayload.data.sessions.filter((session) => !session.projectId || !archivedProjectIds.has(session.projectId)) + : []; + + setArchivedProjects(nextProjects); + setArchivedSessions(nextStandaloneSessions); + } catch (error) { + console.error('[Sidebar] Failed to load archived sessions:', error); + } finally { + setIsArchivedSessionsLoading(false); + } + }, []); + useEffect(() => { if (migrationStartedRef.current) { return; @@ -227,6 +281,20 @@ export function useSidebarController({ void migrateLegacyStars(); }, [onRefresh]); + useEffect(() => { + void fetchArchivedSessions(); + }, [fetchArchivedSessions]); + + useEffect(() => { + if (searchMode !== 'archived') { + return; + } + + // Refresh archive contents when the archived tab opens so restore actions + // and background synchronizer updates are reflected without a full reload. + void fetchArchivedSessions(); + }, [fetchArchivedSessions, searchMode]); + useEffect(() => { setOptimisticStarByProjectId((previous) => { if (previous.size === 0) { @@ -519,6 +587,56 @@ export function useSidebarController({ [debouncedSearchQuery, sortedProjects], ); + const filteredArchivedSessions = useMemo(() => { + const normalizedSearch = debouncedSearchQuery.trim().toLowerCase(); + if (!normalizedSearch) { + return archivedSessions; + } + + return archivedSessions.filter((session) => { + const searchableFields = [ + session.sessionTitle, + session.projectDisplayName, + session.projectPath ?? '', + session.provider, + ]; + + return searchableFields.some((value) => value.toLowerCase().includes(normalizedSearch)); + }); + }, [archivedSessions, debouncedSearchQuery]); + + const filteredArchivedProjects = useMemo(() => { + const normalizedSearch = debouncedSearchQuery.trim().toLowerCase(); + if (!normalizedSearch) { + return archivedProjects; + } + + return archivedProjects.filter((project) => { + const projectMatches = [ + project.displayName, + project.fullPath || '', + ].some((value) => value.toLowerCase().includes(normalizedSearch)); + + if (projectMatches) { + return true; + } + + return getAllSessions(project).some((session) => { + const sessionSummary = + typeof session.summary === 'string' && session.summary.trim().length > 0 + ? session.summary + : typeof session.name === 'string' + ? session.name + : ''; + + return [ + sessionSummary, + session.__provider, + ].some((value) => value.toLowerCase().includes(normalizedSearch)); + }); + }); + }, [archivedProjects, debouncedSearchQuery]); + const startEditing = useCallback((project: Project) => { // `editingProject` is keyed by projectId so it stays stable across // display-name mutations that happen while the input is open. @@ -556,17 +674,26 @@ export function useSidebarController({ // Kept with project/provider arguments for component wiring compatibility; // deletion now uses only `sessionId` via /api/providers/sessions/:sessionId. ( - projectId: string, + projectId: string | null, sessionId: string, sessionTitle: string, provider: SessionDeleteConfirmation['provider'] = 'claude', + options: { + isArchived?: boolean; + } = {}, ) => { - setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider }); + setSessionDeleteConfirmation({ + projectId, + sessionId, + sessionTitle, + provider, + isArchived: Boolean(options.isArchived), + }); }, [], ); - const confirmDeleteSession = useCallback(async () => { + const confirmDeleteSession = useCallback(async (hardDelete = false) => { if (!sessionDeleteConfirmation) { return; } @@ -575,10 +702,11 @@ export function useSidebarController({ setSessionDeleteConfirmation(null); try { - const response = await api.deleteSession(sessionId); + const response = await api.deleteSession(sessionId, hardDelete); if (response.ok) { onSessionDelete?.(sessionId); + await fetchArchivedSessions(); } else { const errorText = await response.text(); console.error('[Sidebar] Failed to delete session:', { @@ -591,7 +719,7 @@ export function useSidebarController({ console.error('[Sidebar] Error deleting session:', error); alert(t('messages.deleteSessionError')); } - }, [onSessionDelete, sessionDeleteConfirmation, t]); + }, [fetchArchivedSessions, onSessionDelete, sessionDeleteConfirmation, t]); const requestProjectDelete = useCallback( (project: Project) => { @@ -647,14 +775,88 @@ export function useSidebarController({ [onProjectSelect, setCurrentProject], ); + const openArchivedSession = useCallback((session: ArchivedSessionListItem) => { + const activeProject = session.projectId + ? projects.find((candidate) => candidate.projectId === session.projectId) + : null; + const archivedProject = session.projectId + ? archivedProjects.find((candidate) => candidate.projectId === session.projectId) + : null; + const matchingProject = activeProject ?? archivedProject ?? null; + const sessionPayload: ProjectSession = { + id: session.sessionId, + summary: session.sessionTitle, + __provider: session.provider, + __projectId: matchingProject?.projectId ?? session.projectId ?? undefined, + }; + + // Archived sessions still need a selected project context. Active projects + // come from the normal sidebar list, while archived-project sessions resolve + // through the archive payload loaded by this controller. + if (matchingProject) { + handleProjectSelect(matchingProject); + } + + onSessionSelect(sessionPayload); + }, [archivedProjects, handleProjectSelect, onSessionSelect, projects]); + + const restoreArchivedProject = useCallback(async (projectId: string) => { + try { + const response = await api.restoreProject(projectId); + if (!response.ok) { + const errorText = await response.text(); + console.error('[Sidebar] Failed to restore project:', { + status: response.status, + error: errorText, + }); + alert(t('messages.restoreProjectFailed', 'Failed to restore project. Please try again.')); + return; + } + + await Promise.all([ + Promise.resolve(onRefresh()), + fetchArchivedSessions(), + ]); + } catch (error) { + console.error('[Sidebar] Error restoring project:', error); + alert(t('messages.restoreProjectError', 'Error restoring project. Please try again.')); + } + }, [fetchArchivedSessions, onRefresh, t]); + + const restoreArchivedSession = useCallback(async (sessionId: string) => { + try { + const response = await api.restoreSession(sessionId); + if (!response.ok) { + const errorText = await response.text(); + console.error('[Sidebar] Failed to restore session:', { + status: response.status, + error: errorText, + }); + alert(t('messages.restoreSessionFailed', 'Failed to restore session. Please try again.')); + return; + } + + await Promise.all([ + Promise.resolve(onRefresh()), + fetchArchivedSessions(), + ]); + } catch (error) { + console.error('[Sidebar] Error restoring session:', error); + alert(t('messages.restoreSessionError', 'Error restoring session. Please try again.')); + } + }, [fetchArchivedSessions, onRefresh, t]); + const refreshProjects = useCallback(async () => { setIsRefreshing(true); try { - await onRefresh(); + await Promise.all([ + Promise.resolve(onRefresh()), + fetchArchivedSessions(), + ]); } finally { setIsRefreshing(false); } - }, [onRefresh]); + }, [fetchArchivedSessions, onRefresh]); const updateSessionSummary = useCallback( // `_projectId` and `_provider` are preserved for compatibility with @@ -712,6 +914,10 @@ export function useSidebarController({ sessionDeleteConfirmation, showVersionModal, filteredProjects, + archivedProjects: filteredArchivedProjects, + archivedSessions: filteredArchivedSessions, + archivedSessionsCount: archivedProjects.length + archivedSessions.length, + isArchivedSessionsLoading, toggleProject, handleSessionClick, toggleStarProject, @@ -726,6 +932,9 @@ export function useSidebarController({ requestProjectDelete, confirmDeleteProject, handleProjectSelect, + openArchivedSession, + restoreArchivedProject, + restoreArchivedSession, refreshProjects, updateSessionSummary, collapseSidebar, diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index 6db25126..0f44cf29 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -1,11 +1,26 @@ import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app'; export type ProjectSortOrder = 'name' | 'date'; +export type SidebarSearchMode = 'projects' | 'conversations' | 'archived'; +export type ArchivedProjectListItem = Project & { isArchived: true }; export type SessionWithProvider = ProjectSession & { __provider: LLMProvider; }; +export type ArchivedSessionListItem = { + sessionId: string; + provider: LLMProvider; + projectId: string | null; + projectPath: string | null; + projectDisplayName: string; + sessionTitle: string; + createdAt: string | null; + updatedAt: string | null; + lastActivity: string | null; + isProjectArchived: boolean; +}; + export type DeleteProjectConfirmation = { project: Project; sessionCount: number; @@ -14,10 +29,11 @@ export type DeleteProjectConfirmation = { // Delete confirmation payload used by sidebar UX. `projectId`/`provider` are // kept for wiring compatibility, while API deletion now keys only by sessionId. export type SessionDeleteConfirmation = { - projectId: string; + projectId: string | null; sessionId: string; sessionTitle: string; provider: LLMProvider; + isArchived: boolean; }; export type SidebarProps = { diff --git a/src/components/sidebar/utils/utils.ts b/src/components/sidebar/utils/utils.ts index 2602a633..048c7e21 100644 --- a/src/components/sidebar/utils/utils.ts +++ b/src/components/sidebar/utils/utils.ts @@ -1,4 +1,5 @@ import type { TFunction } from 'i18next'; + import type { Project } from '../../../types/app'; import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types'; @@ -52,44 +53,24 @@ export const clearLegacyStarredProjectIds = () => { } }; +const getCreatedTimestamp = (session: SessionWithProvider): string => { + return String(session.createdAt || session.created_at || ''); +}; + +const getUpdatedTimestamp = (session: SessionWithProvider): string => { + return String(session.lastActivity || ''); +}; + export const getSessionDate = (session: SessionWithProvider): Date => { - if (session.__provider === 'cursor') { - return new Date(session.createdAt || 0); - } - - if (session.__provider === 'codex') { - return new Date(session.createdAt || session.lastActivity || 0); - } - - return new Date(session.lastActivity || session.createdAt || 0); + return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0); }; export const getSessionName = (session: SessionWithProvider, t: TFunction): string => { - if (session.__provider === 'cursor') { - return session.summary || session.name || t('projects.untitledSession'); - } - - if (session.__provider === 'codex') { - return session.summary || session.name || t('projects.codexSession'); - } - - if (session.__provider === 'gemini') { - return session.summary || session.name || t('projects.newSession'); - } - - return session.summary || t('projects.newSession'); + return session.summary || session.name || t('projects.newSession'); }; export const getSessionTime = (session: SessionWithProvider): string => { - if (session.__provider === 'cursor') { - return String(session.createdAt || ''); - } - - if (session.__provider === 'codex') { - return String(session.createdAt || session.lastActivity || ''); - } - - return String(session.lastActivity || session.createdAt || ''); + return getUpdatedTimestamp(session) || getCreatedTimestamp(session); }; export const createSessionViewModel = ( diff --git a/src/components/sidebar/view/Sidebar.tsx b/src/components/sidebar/view/Sidebar.tsx index 97484b01..ebc046c5 100644 --- a/src/components/sidebar/view/Sidebar.tsx +++ b/src/components/sidebar/view/Sidebar.tsx @@ -75,6 +75,10 @@ function Sidebar({ sessionDeleteConfirmation, showVersionModal, filteredProjects, + archivedProjects, + archivedSessions, + archivedSessionsCount, + isArchivedSessionsLoading, toggleProject, handleSessionClick, toggleStarProject, @@ -90,6 +94,9 @@ function Sidebar({ requestProjectDelete, confirmDeleteProject, handleProjectSelect, + openArchivedSession, + restoreArchivedProject, + restoreArchivedSession, refreshProjects, updateSessionSummary, collapseSidebar: handleCollapseSidebar, @@ -184,8 +191,8 @@ function Sidebar({ return ( <> - ) : ( <> - setSearchFilter('')} searchMode={searchMode} - onSearchModeChange={(mode: 'projects' | 'conversations') => { + onSearchModeChange={(mode) => { setSearchMode(mode); if (mode === 'projects') clearConversationResults(); }} conversationResults={conversationResults} isSearching={isSearching} searchProgress={searchProgress} + onRestoreArchivedProject={restoreArchivedProject} + onArchivedSessionClick={openArchivedSession} + onRestoreArchivedSession={restoreArchivedSession} + onDeleteArchivedSession={(session) => { + showDeleteSessionConfirmation( + session.projectId, + session.sessionId, + session.sessionTitle, + session.provider, + { isArchived: true }, + ); + }} onConversationResultClick={(projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => { // `projectId` (DB key) is the canonical identifier post-migration. // The server emits null when it can't resolve a project row for diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 3e675f2d..5ce63b8b 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -1,15 +1,16 @@ import { type ReactNode } from 'react'; -import { Folder, MessageSquare, Search } from 'lucide-react'; +import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react'; import type { TFunction } from 'i18next'; import { ScrollArea } from '../../../../shared/view/ui'; import type { Project } from '../../../../types/app'; import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController'; +import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types'; +import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SidebarFooter from './SidebarFooter'; import SidebarHeader from './SidebarHeader'; import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList'; - -type SearchMode = 'projects' | 'conversations'; +import { getAllSessions } from '../../utils/utils'; function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) { const parts: ReactNode[] = []; @@ -35,19 +36,100 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh ); } +type ArchivedSessionGroup = { + key: string; + projectId: string | null; + projectDisplayName: string; + projectPath: string | null; + isProjectArchived: boolean; + sessions: ArchivedSessionListItem[]; + latestActivity: string | null; +}; + +/** + * Groups archived sessions by project metadata so the archive view preserves + * the same mental model as the active sidebar: projects first, then sessions. + */ +function groupArchivedSessionsByProject(sessions: ArchivedSessionListItem[]): ArchivedSessionGroup[] { + const groups = new Map(); + + for (const session of sessions) { + const key = session.projectId ?? session.projectPath ?? `session:${session.sessionId}`; + const existingGroup = groups.get(key); + + if (existingGroup) { + existingGroup.sessions.push(session); + if (!existingGroup.latestActivity || (session.lastActivity && session.lastActivity > existingGroup.latestActivity)) { + existingGroup.latestActivity = session.lastActivity; + } + continue; + } + + groups.set(key, { + key, + projectId: session.projectId, + projectDisplayName: session.projectDisplayName, + projectPath: session.projectPath, + isProjectArchived: session.isProjectArchived, + sessions: [session], + latestActivity: session.lastActivity, + }); + } + + return [...groups.values()].sort((groupA, groupB) => { + const a = groupA.latestActivity ?? ''; + const b = groupB.latestActivity ?? ''; + return b.localeCompare(a); + }); +} + +function formatCompactArchivedAge(dateString: string | null): string { + if (!dateString) { + return ''; + } + + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return ''; + } + + const diffInMinutes = Math.floor(Math.max(0, Date.now() - date.getTime()) / (1000 * 60)); + if (diffInMinutes < 1) { + return '<1m'; + } + if (diffInMinutes < 60) { + return `${diffInMinutes}m`; + } + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) { + return `${diffInHours}hr`; + } + + return `${Math.floor(diffInHours / 24)}d`; +} + type SidebarContentProps = { isPWA: boolean; isMobile: boolean; isLoading: boolean; projects: Project[]; + archivedProjects: ArchivedProjectListItem[]; + archivedSessions: ArchivedSessionListItem[]; + archivedSessionsCount: number; + isArchivedSessionsLoading: boolean; searchFilter: string; onSearchFilterChange: (value: string) => void; onClearSearchFilter: () => void; - searchMode: SearchMode; - onSearchModeChange: (mode: SearchMode) => void; + searchMode: SidebarSearchMode; + onSearchModeChange: (mode: SidebarSearchMode) => void; conversationResults: ConversationSearchResults | null; isSearching: boolean; searchProgress: SearchProgress | null; + onRestoreArchivedProject: (projectId: string) => void; + onArchivedSessionClick: (session: ArchivedSessionListItem) => void; + onRestoreArchivedSession: (sessionId: string) => void; + onDeleteArchivedSession: (session: ArchivedSessionListItem) => void; // Conversation result clicks pass back the DB projectId (or null when the // server couldn't resolve it). Consumers must handle the null case. onConversationResultClick: (projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void; @@ -70,6 +152,10 @@ export default function SidebarContent({ isMobile, isLoading, projects, + archivedProjects, + archivedSessions, + archivedSessionsCount, + isArchivedSessionsLoading, searchFilter, onSearchFilterChange, onClearSearchFilter, @@ -78,6 +164,10 @@ export default function SidebarContent({ conversationResults, isSearching, searchProgress, + onRestoreArchivedProject, + onArchivedSessionClick, + onRestoreArchivedSession, + onDeleteArchivedSession, onConversationResultClick, onRefresh, isRefreshing, @@ -94,6 +184,7 @@ export default function SidebarContent({ }: SidebarContentProps) { const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2; const hasPartialResults = conversationResults && conversationResults.results.length > 0; + const groupedArchivedSessions = groupArchivedSessionsByProject(archivedSessions); return (
) : null + ) : searchMode === 'archived' ? ( + isArchivedSessionsLoading ? ( +
+
+
+
+

+ {t('archived.loadingTitle', 'Loading archive...')} +

+

+ {t('archived.loadingDescription', 'Fetching hidden workspaces and sessions you can restore later.')} +

+
+ ) : archivedProjects.length === 0 && groupedArchivedSessions.length === 0 ? ( +
+
+ +
+

+ {archivedSessionsCount > 0 + ? t('archived.noMatchingSessions', 'No matching archived items') + : t('archived.emptyTitle', 'No archived items')} +

+

+ {archivedSessionsCount > 0 + ? t('archived.tryDifferentSearch', 'Try a different search term.') + : t('archived.emptyDescription', 'Archived workspaces and sessions will appear here when you hide them from the active list.')} +

+
+ ) : ( +
+
+

+ {`${archivedSessionsCount} ${t( + archivedSessionsCount === 1 ? 'archived.sessionCountOne' : 'archived.sessionCountOther', + archivedSessionsCount === 1 ? 'archived item' : 'archived items', + )}`} +

+
+ {archivedProjects.map((project) => { + const projectSessions = getAllSessions(project); + + return ( +
+
+
+
+ + + {project.displayName} + + + {t('archived.projectArchived', 'Project archived')} + +
+

+ {project.fullPath} +

+
+ +
+ {projectSessions.length > 0 && ( +
+ {projectSessions.map((session) => ( + + ))} +
+ )} +
+ ); + })} + {groupedArchivedSessions.map((group) => ( +
+
+
+
+ + + {group.projectDisplayName} + + {group.isProjectArchived && ( + + {t('archived.projectArchived', 'Project archived')} + + )} +
+ {group.projectPath && ( +

+ {group.projectPath} +

+ )} +
+ + {group.sessions.length} + +
+
+ {group.sessions.map((session) => ( +
+ + + +
+ ))} +
+
+ ))} +
+ ) ) : ( )} diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx index ab1eed7e..5117b8db 100644 --- a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx @@ -1,25 +1,26 @@ -import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react'; +import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react'; import type { TFunction } from 'i18next'; -import { Button, Input } from '../../../../shared/view/ui'; +import { Button, Input, Tooltip } from '../../../../shared/view/ui'; import { IS_PLATFORM } from '../../../../constants/config'; import { cn } from '../../../../lib/utils'; +import type { SidebarSearchMode } from '../../types/types'; import GitHubStarBadge from './GitHubStarBadge'; const MOD_KEY = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'; -type SearchMode = 'projects' | 'conversations'; - type SidebarHeaderProps = { isPWA: boolean; isMobile: boolean; isLoading: boolean; projectsCount: number; + archivedSessionsCount: number; + isArchivedSessionsLoading: boolean; searchFilter: string; onSearchFilterChange: (value: string) => void; onClearSearchFilter: () => void; - searchMode: SearchMode; - onSearchModeChange: (mode: SearchMode) => void; + searchMode: SidebarSearchMode; + onSearchModeChange: (mode: SidebarSearchMode) => void; onRefresh: () => void; isRefreshing: boolean; onCreateProject: () => void; @@ -32,6 +33,8 @@ export default function SidebarHeader({ isMobile, isLoading, projectsCount, + archivedSessionsCount, + isArchivedSessionsLoading, searchFilter, onSearchFilterChange, onClearSearchFilter, @@ -43,6 +46,13 @@ export default function SidebarHeader({ onCollapseSidebar, t, }: SidebarHeaderProps) { + const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading; + const searchPlaceholder = searchMode === 'conversations' + ? t('search.conversationsPlaceholder') + : searchMode === 'archived' + ? t('search.archivedPlaceholder', 'Search archived sessions...') + : t('projects.searchPlaceholder'); + const LogoBlock = () => (
@@ -113,7 +123,7 @@ export default function SidebarHeader({ {/* Search bar */} - {projectsCount > 0 && !isLoading && ( + {showSearchTools && (
{/* Search mode toggle */}
@@ -143,12 +153,28 @@ export default function SidebarHeader({ {t('search.modeConversations')} + + +
onSearchFilterChange(event.target.value)} className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-14 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" @@ -215,7 +241,7 @@ export default function SidebarHeader({
{/* Mobile search */} - {projectsCount > 0 && !isLoading && ( + {showSearchTools && (
+ + +
onSearchFilterChange(event.target.value)} className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" diff --git a/src/components/sidebar/view/subcomponents/SidebarModals.tsx b/src/components/sidebar/view/subcomponents/SidebarModals.tsx index 27127541..28404940 100644 --- a/src/components/sidebar/view/subcomponents/SidebarModals.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarModals.tsx @@ -25,7 +25,7 @@ type SidebarModalsProps = { onConfirmDeleteProject: (deleteData?: boolean) => void; sessionDeleteConfirmation: SessionDeleteConfirmation | null; onCancelDeleteSession: () => void; - onConfirmDeleteSession: () => void; + onConfirmDeleteSession: (hardDelete?: boolean) => void; showVersionModal: boolean; onCloseVersionModal: () => void; releaseInfo: ReleaseInfo | null; @@ -133,7 +133,7 @@ export default function SidebarModals({ onClick={() => onConfirmDeleteProject(false)} > - {t('deleteConfirmation.removeFromSidebar')} + {t('deleteConfirmation.archiveProject', 'Archive project')}
-
- +
+ {!sessionDeleteConfirmation.isArchived && ( + + )} +
diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 7da02cb2..4e97a6b4 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -239,7 +239,7 @@ export default function SidebarSessionItem({ event.stopPropagation(); requestDeleteSession(); }} - title={t('tooltips.deleteSession')} + title={t('tooltips.deleteSessionOptions', 'Archive or permanently delete this session')} > diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index d920fba2..a5397b6b 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -435,9 +435,7 @@ export function useProjectsState({ } } - const hasActiveSession = - (selectedSession && activeSessions.has(selectedSession.id)) || - (activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-'))); + const hasActiveSession = Boolean(selectedSession && activeSessions.has(selectedSession.id)); const updatedProjectsWithTaskMaster = mergeTaskMasterCache(projectsMessage.projects, projects); const updatedProjects = mergeExpandedSessionPages(projects, updatedProjectsWithTaskMaster); diff --git a/src/hooks/useSessionProtection.ts b/src/hooks/useSessionProtection.ts index 0c3d1bab..cbdcdda1 100644 --- a/src/hooks/useSessionProtection.ts +++ b/src/hooks/useSessionProtection.ts @@ -44,23 +44,6 @@ export function useSessionProtection() { }); }, []); - const replaceTemporarySession = useCallback((realSessionId?: string | null) => { - if (!realSessionId) { - return; - } - - setActiveSessions((prev) => { - const next = new Set(); - for (const sessionId of prev) { - if (!sessionId.startsWith('new-session-')) { - next.add(sessionId); - } - } - next.add(realSessionId); - return next; - }); - }, []); - return { activeSessions, processingSessions, @@ -68,6 +51,5 @@ export function useSessionProtection() { markSessionAsInactive, markSessionAsProcessing, markSessionAsNotProcessing, - replaceTemporarySession, }; } diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 86925048..1a720d3d 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -40,6 +40,20 @@ export interface NormalizedMessage { // kind-specific fields (flat for simplicity) role?: 'user' | 'assistant'; content?: string; + /** + * Mirrors optional transcript metadata from the server. + * + * These fields are currently used by Claude history normalization so local + * slash commands, local stdout, and compact summaries do not disappear when + * the session store hydrates from REST history. + */ + displayText?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; images?: string[]; toolName?: string; toolInput?: unknown; diff --git a/src/utils/api.js b/src/utils/api.js index 0ac8d426..999ee316 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -54,6 +54,7 @@ export const api = { // After the projectName → projectId migration the path/query identifier is // the DB-assigned `projectId`; parameter names reflect that for clarity. projects: () => authenticatedFetch('/api/projects'), + archivedProjects: () => authenticatedFetch('/api/projects/archived'), projectSessions: (projectId, { limit = 20, offset = 0 } = {}) => { const params = new URLSearchParams(); params.set('limit', String(limit)); @@ -78,9 +79,28 @@ export const api = { method: 'PUT', body: JSON.stringify({ displayName }), }), - deleteSession: (sessionId) => - authenticatedFetch(`/api/providers/sessions/${sessionId}`, { + restoreProject: (projectId) => + authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/restore`, { + method: 'POST', + }), + // Session deletion now mirrors project deletion: + // - default: archive only (`isArchived = 1`) + // - hardDelete: remove the row and, by default, its persisted transcript file + deleteSession: (sessionId, hardDelete = false) => { + const params = new URLSearchParams(); + if (hardDelete) { + params.set('force', 'true'); + } + const qs = params.toString(); + return authenticatedFetch(`/api/providers/sessions/${sessionId}${qs ? `?${qs}` : ''}`, { method: 'DELETE', + }); + }, + getArchivedSessions: () => + authenticatedFetch('/api/providers/sessions/archived'), + restoreSession: (sessionId) => + authenticatedFetch(`/api/providers/sessions/${sessionId}/restore`, { + method: 'POST', }), renameSession: (sessionId, summary) => authenticatedFetch(`/api/providers/sessions/${sessionId}`, {