From 1f205434b21a09a68a90374292c360de19c86b3b Mon Sep 17 00:00:00 2001 From: bbsngg Date: Sun, 12 Apr 2026 17:36:11 -0400 Subject: [PATCH 1/9] Reapply "Topic/upstream session lifecycle 20260412" This reverts commit 663fda209cfd05469b45174005f915eb41532ab0. --- CHANGELOG.md | 37 + README.md | 2 +- README.zh-CN.md | 2 +- .../__tests__/codex-session-events.test.mjs | 35 + .../__tests__/gemini-session-index.test.mjs | 49 +- server/__tests__/project-config-path.test.mjs | 134 ++ server/__tests__/project-sync-dedup.test.mjs | 64 +- server/__tests__/session-delete.test.mjs | 87 +- server/claude-sdk.js | 4 +- server/cursor-cli.js | 6 +- server/gemini-cli.js | 8 +- server/index.js | 364 ++- server/openai-codex.js | 10 +- server/projects.js | 451 +++- server/utils/__tests__/safePath.test.js | 23 +- server/utils/codexSessionEvents.js | 13 + .../chat/hooks/useChatComposerState.ts | 2103 +++++++++++++---- .../chat/hooks/useChatRealtimeHandlers.ts | 1858 ++++++++++++--- .../chat/hooks/useChatSessionState.ts | 721 +++++- src/components/chat/types/types.ts | 51 +- .../chat/utils/__tests__/codexQueue.test.ts | 189 ++ .../__tests__/sessionContextSummary.test.ts | 225 +- .../utils/__tests__/sessionLoadGuards.test.ts | 70 + .../__tests__/sessionMessageCache.test.ts | 30 + .../__tests__/sessionSnapshotCache.test.ts | 46 + src/components/chat/utils/chatStorage.ts | 130 +- src/components/chat/utils/codexQueue.ts | 171 ++ .../chat/utils/sessionContextSummary.ts | 631 +---- .../chat/utils/sessionFilterDebug.ts | 86 + .../chat/utils/sessionLoadGuards.ts | 22 + .../chat/utils/sessionMessageCache.ts | 24 + .../chat/utils/sessionSnapshotCache.ts | 60 + src/components/chat/view/ChatInterface.tsx | 4 +- .../subcomponents/ChatContextFilePreview.tsx | 134 +- .../view/subcomponents/ChatContextSidebar.tsx | 76 +- .../view/subcomponents/ShellWorkspace.tsx | 2 +- .../sidebar/hooks/useSidebarController.ts | 13 +- .../__tests__/sessionListBehavior.test.ts | 146 ++ src/components/sidebar/utils/utils.ts | 63 +- .../subcomponents/SidebarProjectSessions.tsx | 2 +- .../view/subcomponents/SidebarSessionItem.tsx | 18 +- src/constants/sessionEvents.ts | 13 + .../__tests__/projectsSessionSync.test.ts | 175 ++ src/hooks/projectsSessionSync.ts | 239 ++ src/hooks/useProjectsState.ts | 190 +- src/hooks/useSessionProtection.ts | 166 +- src/utils/__tests__/sessionScope.test.ts | 89 + src/utils/providerPolicy.ts | 35 + src/utils/sessionScope.ts | 93 + 49 files changed, 7129 insertions(+), 2035 deletions(-) create mode 100644 server/__tests__/codex-session-events.test.mjs create mode 100644 server/__tests__/project-config-path.test.mjs create mode 100644 server/utils/codexSessionEvents.js create mode 100644 src/components/chat/utils/__tests__/codexQueue.test.ts create mode 100644 src/components/chat/utils/__tests__/sessionLoadGuards.test.ts create mode 100644 src/components/chat/utils/__tests__/sessionMessageCache.test.ts create mode 100644 src/components/chat/utils/__tests__/sessionSnapshotCache.test.ts create mode 100644 src/components/chat/utils/codexQueue.ts create mode 100644 src/components/chat/utils/sessionFilterDebug.ts create mode 100644 src/components/chat/utils/sessionLoadGuards.ts create mode 100644 src/components/chat/utils/sessionMessageCache.ts create mode 100644 src/components/chat/utils/sessionSnapshotCache.ts create mode 100644 src/components/sidebar/utils/__tests__/sessionListBehavior.test.ts create mode 100644 src/constants/sessionEvents.ts create mode 100644 src/hooks/__tests__/projectsSessionSync.test.ts create mode 100644 src/hooks/projectsSessionSync.ts create mode 100644 src/utils/__tests__/sessionScope.test.ts create mode 100644 src/utils/providerPolicy.ts create mode 100644 src/utils/sessionScope.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb35ff60..1695a08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## Unreleased + +### Public Upstream Sync - 2026-04-11 + +#### Included in this upstream-safe branch +- Chat composer queue and steer workflow: + - Added durable queued-turn data structures and queue reconciliation helpers. + - Added steer promotion, paused queue handling, queued-turn resume, and queue cleanup after turn settlement. + - Added queue/session-scope tests for `codexQueue`, `sessionLoadGuards`, `sessionSnapshotCache`, and `sessionScope`. +- Session stability and protocol hardening: + - Added explicit WebSocket lifecycle protocol messages: `session-accepted`, `session-busy`, `session-state-changed`. + - Added project-scoped event enrichment for session lifecycle payloads to avoid cross-session/cross-project ambiguity. + - Added lifecycle projection from provider completion/error messages into normalized session-state events. + - Added session-created `projectName` metadata for Claude/Cursor/Gemini session initialization paths. + - Removed unused `projectPath` from `session-accepted` payloads; downstream should rely on `projectName` + scoped identifiers. +- Nano chain compatibility: + - UI provider/model selection no longer surfaces Nano by default in the upstream-safe branch, while server-side `nano-command` handling remains for compatibility. + - Preserved Nano command path and active session reporting in WebSocket session status flows. + - Ensured session lifecycle protocol is emitted consistently for Nano just like other providers. + +#### Explicitly excluded (local/private only, not part of upstream PR) +- Codex-only product strategy and provider lock-in controls. +- External auth / license refresh-heartbeat-offline-grace stack. +- Project root hard-cut and Codex session backfill private policy behavior. +- LingZhi/LingzhiLab branding replacements and private demo/link route changes. + +#### Cross-platform notes +- No platform-specific server behavior was hardcoded for this sync. +- Session protocol additions are transport-level and provider-agnostic; Windows-specific stability paths do not alter macOS behavior. + +#### Validation +- Passed: `node --check server/index.js` +- Passed: `node --check server/claude-sdk.js` +- Passed: `node --check server/cursor-cli.js` +- Passed: `node --check server/gemini-cli.js` +- Not runnable in current environment (missing local dev dependencies): `vitest`, `tsc` + ## Dr. Claw v1.1.1 - 2026-03-30 ### Highlights diff --git a/README.md b/README.md index 1518afdb..9b32a6f4 100644 --- a/README.md +++ b/README.md @@ -644,7 +644,7 @@ When you first open Dr. Claw you will see the **Projects** sidebar. You have two - **Open an existing project** — Dr. Claw auto-discovers registered projects and linked sessions from Claude Code, Codex, and Gemini. - **Create a new project** — Click the **"+"** button, choose a directory on your machine, and Dr. Claw will set up the workspace: agent folders such as `.claude/`, `.agents/`, `.gemini/`, standard workspace metadata, linked `skills/` directories, preset research dirs (`Survey/references`, `Survey/reports`, `Ideation/ideas`, `Ideation/references`, `Experiment/code_references`, `Experiment/datasets`, `Experiment/core_code`, `Experiment/analysis`, `Publication/paper`, `Promotion/homepage`, `Promotion/slides`, `Promotion/audio`, `Promotion/video`), and **instance.json** at the project root with absolute paths for those directories. Cursor agent support is coming soon. -> **Default project storage path:** New projects are stored under `~/dr-claw` by default. You can change this in **Settings → Appearance → Default Project Path**, or set the `WORKSPACES_ROOT` environment variable. The setting is persisted in `~/.claude/project-config.json`. +> **Default project storage path:** New projects are stored under `~/dr-claw` by default. You can change this in **Settings → Appearance → Default Project Path**, or set the `WORKSPACES_ROOT` environment variable. The setting is persisted in `~/.dr-claw/project-config.json` (with automatic one-time migration from `~/.claude/project-config.json`). diff --git a/README.zh-CN.md b/README.zh-CN.md index d5294ee1..9a7f6fe8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -640,7 +640,7 @@ Dr. Claw 的核心功能是 **Research Lab**。 - **打开已有项目** — Dr. Claw 会自动发现已注册项目,以及来自 Claude Code、Codex、Gemini 的关联会话。 - **创建新项目** — 点击 **"+"** 按钮,选择本机的一个目录,Dr. Claw 会创建:`.claude/`、`.agents/`、`.gemini/` 等 Agent 目录、标准工作区元数据、链接的 `skills/` 目录、预设研究目录(`Survey/references`、`Survey/reports`、`Ideation/ideas`、`Ideation/references`、`Experiment/code_references`、`Experiment/datasets`、`Experiment/core_code`、`Experiment/analysis`、`Publication/paper`、`Promotion/homepage`、`Promotion/slides`、`Promotion/audio`、`Promotion/video`),以及项目根目录下的 **instance.json**(上述目录的绝对路径写入其中)。Cursor Agent 支持即将推出。 -> **默认项目存储路径:** 新项目默认存储在 `~/dr-claw` 目录下。可在 **Settings → Appearance → Default Project Path** 中修改,也可通过环境变量 `WORKSPACES_ROOT` 设置。该配置持久化在 `~/.claude/project-config.json` 中。 +> **默认项目存储路径:** 新项目默认存储在 `~/dr-claw` 目录下。可在 **Settings → Appearance → Default Project Path** 中修改,也可通过环境变量 `WORKSPACES_ROOT` 设置。该配置持久化在 `~/.dr-claw/project-config.json` 中(会自动一次性迁移 `~/.claude/project-config.json`)。 diff --git a/server/__tests__/codex-session-events.test.mjs b/server/__tests__/codex-session-events.test.mjs new file mode 100644 index 00000000..10610675 --- /dev/null +++ b/server/__tests__/codex-session-events.test.mjs @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCodexSessionCreatedEvent } from '../utils/codexSessionEvents.js'; + +describe('codex session event payloads', () => { + it('includes projectName when provided', () => { + const event = buildCodexSessionCreatedEvent({ + sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade', + sessionMode: 'research', + projectName: 'C--Users-Xingjian-Hu-dr-claw-jyw', + }); + + expect(event).toEqual({ + type: 'session-created', + sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade', + provider: 'codex', + mode: 'research', + projectName: 'C--Users-Xingjian-Hu-dr-claw-jyw', + }); + }); + + it('keeps backward-compatible payload shape when projectName is missing', () => { + const event = buildCodexSessionCreatedEvent({ + sessionId: 'session-no-project', + sessionMode: 'workspace_qa', + }); + + expect(event).toEqual({ + type: 'session-created', + sessionId: 'session-no-project', + provider: 'codex', + mode: 'workspace_qa', + }); + }); +}); diff --git a/server/__tests__/gemini-session-index.test.mjs b/server/__tests__/gemini-session-index.test.mjs index 8dbf89af..bea3b518 100644 --- a/server/__tests__/gemini-session-index.test.mjs +++ b/server/__tests__/gemini-session-index.test.mjs @@ -8,12 +8,57 @@ const originalUserProfile = process.env.USERPROFILE; const originalDatabasePath = process.env.DATABASE_PATH; let tempRoot = null; +let activeDatabaseModule = null; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function closeTestDatabase() { + if (!activeDatabaseModule?.db?.close) { + return; + } + + const maxAttempts = 6; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + activeDatabaseModule.db.close(); + activeDatabaseModule = null; + return; + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + await sleep(30 * attempt); + } + } +} + +async function removeTempRootWithRetry(targetPath) { + if (!targetPath) { + return; + } + + const maxAttempts = 8; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + if (error?.code !== 'EBUSY' || attempt === maxAttempts) { + throw error; + } + await sleep(50 * attempt); + } + } +} async function loadTestModules() { vi.resetModules(); const projects = await import('../projects.js'); const database = await import('../database/db.js'); await database.initializeDatabase(); + activeDatabaseModule = database; return { projects, database }; } @@ -26,6 +71,8 @@ describe('Gemini API session indexing', () => { }); afterEach(async () => { + await closeTestDatabase(); + vi.resetModules(); if (originalHome === undefined) delete process.env.HOME; @@ -38,7 +85,7 @@ describe('Gemini API session indexing', () => { else process.env.DATABASE_PATH = originalDatabasePath; if (tempRoot) { - await rm(tempRoot, { recursive: true, force: true }); + await removeTempRootWithRetry(tempRoot); tempRoot = null; } }); diff --git a/server/__tests__/project-config-path.test.mjs b/server/__tests__/project-config-path.test.mjs new file mode 100644 index 00000000..bdf60023 --- /dev/null +++ b/server/__tests__/project-config-path.test.mjs @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalDatabasePath = process.env.DATABASE_PATH; + +let tempRoot = null; +let activeDatabaseModule = null; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function closeTestDatabase() { + if (!activeDatabaseModule?.db?.close) { + return; + } + + const maxAttempts = 6; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + activeDatabaseModule.db.close(); + activeDatabaseModule = null; + return; + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + await sleep(30 * attempt); + } + } +} + +async function removeTempRootWithRetry(targetPath) { + if (!targetPath) { + return; + } + + const maxAttempts = 8; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + if (error?.code !== 'EBUSY' || attempt === maxAttempts) { + throw error; + } + await sleep(50 * attempt); + } + } +} + +async function loadProjectsModule() { + vi.resetModules(); + const projects = await import('../projects.js'); + activeDatabaseModule = await import('../database/db.js'); + return projects; +} + +describe('project config path migration', () => { + beforeEach(async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-project-config-')); + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db'); + }); + + afterEach(async () => { + await closeTestDatabase(); + vi.resetModules(); + + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + + if (originalDatabasePath === undefined) delete process.env.DATABASE_PATH; + else process.env.DATABASE_PATH = originalDatabasePath; + + if (tempRoot) { + await removeTempRootWithRetry(tempRoot); + tempRoot = null; + } + }); + + it('prefers ~/.dr-claw/project-config.json when both current and legacy files exist', async () => { + const currentConfigPath = path.join(tempRoot, '.dr-claw', 'project-config.json'); + const legacyConfigPath = path.join(tempRoot, '.claude', 'project-config.json'); + + await mkdir(path.dirname(currentConfigPath), { recursive: true }); + await mkdir(path.dirname(legacyConfigPath), { recursive: true }); + + await writeFile(currentConfigPath, JSON.stringify({ marker: 'current', _workspacesRoot: path.join(tempRoot, 'dr-claw') }, null, 2), 'utf8'); + await writeFile(legacyConfigPath, JSON.stringify({ marker: 'legacy', _workspacesRoot: path.join(tempRoot, 'legacy-root') }, null, 2), 'utf8'); + + const projects = await loadProjectsModule(); + const config = await projects.loadProjectConfig(); + + expect(config.marker).toBe('current'); + expect(config._workspacesRoot).toBe(path.join(tempRoot, 'dr-claw')); + }); + + it('migrates legacy ~/.claude/project-config.json into ~/.dr-claw/project-config.json once', async () => { + const currentConfigPath = path.join(tempRoot, '.dr-claw', 'project-config.json'); + const legacyConfigPath = path.join(tempRoot, '.claude', 'project-config.json'); + const legacyConfig = { + marker: 'legacy-only', + _workspacesRoot: path.join(tempRoot, 'workspaces'), + }; + + await mkdir(path.dirname(legacyConfigPath), { recursive: true }); + await writeFile(legacyConfigPath, JSON.stringify(legacyConfig, null, 2), 'utf8'); + + const projects = await loadProjectsModule(); + const loadedConfig = await projects.loadProjectConfig(); + expect(loadedConfig).toEqual(legacyConfig); + + const migratedRaw = await readFile(currentConfigPath, 'utf8'); + expect(JSON.parse(migratedRaw)).toEqual(legacyConfig); + + const updated = { ...loadedConfig, marker: 'saved-to-current' }; + await projects.saveProjectConfig(updated); + + const currentAfterSave = JSON.parse(await readFile(currentConfigPath, 'utf8')); + expect(currentAfterSave.marker).toBe('saved-to-current'); + + const legacyAfterSave = JSON.parse(await readFile(legacyConfigPath, 'utf8')); + expect(legacyAfterSave.marker).toBe('legacy-only'); + }); +}); diff --git a/server/__tests__/project-sync-dedup.test.mjs b/server/__tests__/project-sync-dedup.test.mjs index 71f7c6d2..a5bae458 100644 --- a/server/__tests__/project-sync-dedup.test.mjs +++ b/server/__tests__/project-sync-dedup.test.mjs @@ -8,11 +8,56 @@ const originalUserProfile = process.env.USERPROFILE; const originalDatabasePath = process.env.DATABASE_PATH; let tempRoot = null; +let activeDatabaseModule = null; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function closeTestDatabase() { + if (!activeDatabaseModule?.db?.close) { + return; + } + + const maxAttempts = 6; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + activeDatabaseModule.db.close(); + activeDatabaseModule = null; + return; + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + await sleep(30 * attempt); + } + } +} + +async function removeTempRootWithRetry(targetPath) { + if (!targetPath) { + return; + } + + const maxAttempts = 8; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + if (error?.code !== 'EBUSY' || attempt === maxAttempts) { + throw error; + } + await sleep(50 * attempt); + } + } +} async function loadTestModules() { vi.resetModules(); const database = await import('../database/db.js'); await database.initializeDatabase(); + activeDatabaseModule = database; const projects = await import('../projects.js'); return { projects, database }; } @@ -32,6 +77,8 @@ describe('project sync and dedup (PR #89)', () => { }); afterEach(async () => { + await closeTestDatabase(); + vi.resetModules(); if (originalHome === undefined) delete process.env.HOME; @@ -44,7 +91,7 @@ describe('project sync and dedup (PR #89)', () => { else process.env.DATABASE_PATH = originalDatabasePath; if (tempRoot) { - await rm(tempRoot, { recursive: true, force: true }); + await removeTempRootWithRetry(tempRoot); tempRoot = null; } }); @@ -170,4 +217,19 @@ describe('project sync and dedup (PR #89)', () => { upsertSpy.mockRestore(); }); }); + + describe('owner id fallback guard', () => { + it('falls back to the authenticated user when project config owner is invalid', async () => { + const { projects, database } = await loadTestModules(); + const userId = createTestUser(database, 'owner-fallback-user'); + + const resolvedOwner = await projects.resolveValidProjectOwnerUserId( + { ownerUserId: 9999 }, + null, + userId, + ); + + expect(resolvedOwner).toBe(userId); + }); + }); }); diff --git a/server/__tests__/session-delete.test.mjs b/server/__tests__/session-delete.test.mjs index a7370bf5..bd106136 100644 --- a/server/__tests__/session-delete.test.mjs +++ b/server/__tests__/session-delete.test.mjs @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import { mkdtemp, mkdir, rm, unlink, writeFile } from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -8,12 +8,57 @@ const originalUserProfile = process.env.USERPROFILE; const originalDatabasePath = process.env.DATABASE_PATH; let tempRoot = null; +let activeDatabaseModule = null; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function closeTestDatabase() { + if (!activeDatabaseModule?.db?.close) { + return; + } + + const maxAttempts = 6; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + activeDatabaseModule.db.close(); + activeDatabaseModule = null; + return; + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + await sleep(30 * attempt); + } + } +} + +async function removeTempRootWithRetry(targetPath) { + if (!targetPath) { + return; + } + + const maxAttempts = 8; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + if (error?.code !== 'EBUSY' || attempt === maxAttempts) { + throw error; + } + await sleep(50 * attempt); + } + } +} async function loadTestModules() { vi.resetModules(); const projects = await import('../projects.js'); const database = await import('../database/db.js'); await database.initializeDatabase(); + activeDatabaseModule = database; return { projects, database }; } @@ -71,6 +116,8 @@ describe('session deletion fallbacks', () => { }); afterEach(async () => { + await closeTestDatabase(); + vi.resetModules(); if (originalHome === undefined) delete process.env.HOME; @@ -83,7 +130,7 @@ describe('session deletion fallbacks', () => { else process.env.DATABASE_PATH = originalDatabasePath; if (tempRoot) { - await rm(tempRoot, { recursive: true, force: true }); + await removeTempRootWithRetry(tempRoot); tempRoot = null; } }); @@ -144,6 +191,42 @@ describe('session deletion fallbacks', () => { expect(assistantMessages.some((entry) => entry.message.content.includes('Codex responded successfully'))).toBe(true); }); + it('short-circuits temporary Codex session ids without not-found warnings', async () => { + const { projects } = await loadTestModules(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await projects.getCodexSessionMessages('new-session-12345'); + expect(result).toEqual({ messages: [], total: 0, hasMore: false }); + + expect( + warnSpy.mock.calls.some((args) => + String(args?.[0] || '').includes('Codex session file not found'), + ), + ).toBe(false); + + warnSpy.mockRestore(); + }); + + it('invalidates cached Codex session file path when the source file is deleted', async () => { + const { projects } = await loadTestModules(); + const sessionId = '019d3967-a181-7171-9e9f-7b73811c0d99'; + + const sessionFile = await writeCodexSessionFile({ + relativePath: path.join('2026', '04', '12', 'rollout-mismatched-cache-test.jsonl'), + sessionId, + cwd: path.join(tempRoot, 'workspace', 'proj-cache'), + userMessage: 'Cache locator test', + assistantMessage: 'Cache locator reply', + }); + + const resolvedPath = await projects.resolveCodexSessionFilePath(sessionId); + expect(resolvedPath).toBe(sessionFile); + + await unlink(sessionFile); + const resolvedAfterDeletion = await projects.resolveCodexSessionFilePath(sessionId); + expect(resolvedAfterDeletion).toBeNull(); + }); + it('indexes Codex sessions using the real session id from metadata', async () => { const { projects } = await loadTestModules(); const sessionId = '019d3967-a181-7171-9e9f-7b73811c0d71'; diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 8e8f28c0..59441583 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -680,7 +680,8 @@ async function queryClaudeSDK(command, options = {}, ws) { type: 'session-created', sessionId: capturedSessionId, provider: 'claude', - mode: sessionMode || 'research' + mode: sessionMode || 'research', + projectName: sessionProjectPath ? encodeProjectPath(sessionProjectPath) : undefined, }); } else { console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent); @@ -778,6 +779,7 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionId: capturedSessionId, provider: 'claude', mode: sessionMode || 'research', + projectName: sessionProjectPath ? encodeProjectPath(sessionProjectPath) : undefined, }); } diff --git a/server/cursor-cli.js b/server/cursor-cli.js index 57ac1b94..7f36e816 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; import { resolveCursorCliCommand } from './utils/cursorCommand.js'; import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js'; +import { encodeProjectPath } from './projects.js'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -54,6 +55,7 @@ async function spawnCursor(command, options = {}, ws) { // Use cwd (actual project directory) instead of projectPath const workingDir = cwd || projectPath || process.cwd(); + const encodedProjectName = workingDir ? encodeProjectPath(workingDir) : undefined; const cursorCommand = resolveCursorCliCommand(); // Synchronous (better-sqlite3) — no await needed. @@ -156,6 +158,8 @@ async function spawnCursor(command, options = {}, ws) { ws.send({ type: 'session-created', sessionId: capturedSessionId, + provider: 'cursor', + projectName: encodedProjectName, model: response.model, cwd: response.cwd, mode: sessionMode || 'research', @@ -271,7 +275,7 @@ async function spawnCursor(command, options = {}, ws) { // Clean up process reference const finalSessionId = capturedSessionId || sessionId || processKey; ws.send({ - type: 'claude-complete', + type: 'cursor-complete', sessionId: finalSessionId, exitCode: code, isNewSession: !sessionId && !!command // Flag to indicate this was a new session diff --git a/server/gemini-cli.js b/server/gemini-cli.js index e587fe1d..8f705340 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -931,7 +931,13 @@ export async function spawnGemini(command, options = {}, ws) { stageTagKeys, tagSource: stageTagSource, }); - ws.send({ type: 'session-created', sessionId: capturedSessionId, provider: 'gemini', mode: sessionMode || 'research' }); + ws.send({ + type: 'session-created', + sessionId: capturedSessionId, + provider: 'gemini', + mode: sessionMode || 'research', + projectName: workingDir ? encodeProjectPath(workingDir) : undefined, + }); } } break; diff --git a/server/index.js b/server/index.js index 824c4a51..44f4c9f2 100755 --- a/server/index.js +++ b/server/index.js @@ -42,7 +42,7 @@ import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; -import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; +import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, encodeProjectPath, resolveCodexSessionFilePath } from './projects.js'; import { getProjectTokenUsageSummary } from './project-token-usage.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getCursorSessionStartTime, getActiveCursorSessions } from './cursor-cli.js'; @@ -71,7 +71,7 @@ import computeRoutes from './routes/compute.js'; import newsRoutes from './routes/news.js'; import autoResearchRoutes from './routes/auto-research.js'; import referencesRoutes from './routes/references.js'; -import { initializeDatabase, sessionDb, tagDb } from './database/db.js'; +import { initializeDatabase, projectDb, sessionDb, tagDb } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; import { enqueueTelemetryEvent } from './telemetry.js'; @@ -1358,6 +1358,135 @@ wss.on('connection', (ws, request) => { } }); +function inferProviderFromMessageType(type, fallbackProvider = null) { + const messageType = String(type || ''); + if (messageType.startsWith('claude-')) return 'claude'; + if (messageType.startsWith('cursor-')) return 'cursor'; + if (messageType.startsWith('codex-')) return 'codex'; + if (messageType.startsWith('gemini-')) return 'gemini'; + if (messageType.startsWith('openrouter-')) return 'openrouter'; + if (messageType.startsWith('localgpu-')) return 'local'; + if (messageType.startsWith('nano-')) return 'nano'; + return fallbackProvider || null; +} + +const DEBUG_SESSION_LIFECYCLE = process.env.DEBUG_SESSION_LIFECYCLE === '1'; +const warnedUnknownLifecycleProjectPaths = new Set(); + +function isKnownLifecycleProjectPath(projectPath) { + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return false; + } + + const normalizedPath = path.resolve(projectPath); + if (!fs.existsSync(normalizedPath)) { + return false; + } + + const encodedProjectName = encodeProjectPath(normalizedPath); + return Boolean( + projectDb.getProjectByPath(normalizedPath) || + projectDb.getProjectById(encodedProjectName), + ); +} + +function warnUnknownLifecycleProjectPath(projectPath) { + const normalizedPath = path.resolve(projectPath); + if (warnedUnknownLifecycleProjectPaths.has(normalizedPath)) { + return; + } + + warnedUnknownLifecycleProjectPaths.add(normalizedPath); + console.warn(`[WARN] Ignoring lifecycle projectPath that is not a known project: ${normalizedPath}`); +} + +function resolveProjectName(projectName = null, projectPath = null) { + if (typeof projectName === 'string' && projectName.trim().length > 0) { + return projectName; + } + + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return null; + } + + const normalizedProjectPath = path.resolve(projectPath); + if (!isKnownLifecycleProjectPath(normalizedProjectPath)) { + warnUnknownLifecycleProjectPath(normalizedProjectPath); + return null; + } + + try { + return encodeProjectPath(normalizedProjectPath); + } catch (error) { + if (DEBUG_SESSION_LIFECYCLE) { + console.debug('[DEBUG] Failed to encode project path for lifecycle payload:', normalizedProjectPath, error?.message || error); + } + return null; + } +} + +function enrichSessionEventPayload(payload, fallbackProjectPath = null) { + if (!payload || typeof payload !== 'object') { + return payload; + } + + const messageType = String(payload.type || ''); + if (!messageType.startsWith('session-')) { + return payload; + } + + const resolvedProjectName = resolveProjectName( + payload.projectName, + payload.projectPath || fallbackProjectPath || null, + ); + if (!resolvedProjectName || payload.projectName === resolvedProjectName) { + return payload; + } + + return { + ...payload, + projectName: resolvedProjectName, + }; +} + +function buildLifecycleMessageFromPayload(payload, fallbackProvider = null, fallbackProjectName = null) { + if (!payload || typeof payload !== 'object') { + return null; + } + + const messageType = String(payload.type || ''); + let state = null; + + if (messageType === 'cursor-result' || messageType.endsWith('-complete')) { + state = 'completed'; + } else if (messageType.endsWith('-error')) { + state = 'failed'; + } + + if (!state) { + return null; + } + + const provider = inferProviderFromMessageType( + messageType, + typeof payload.provider === 'string' ? payload.provider : fallbackProvider, + ); + const projectName = resolveProjectName( + payload.projectName || fallbackProjectName || null, + payload.projectPath || null, + ); + + return { + type: 'session-state-changed', + provider, + sessionId: payload.actualSessionId || payload.sessionId || null, + state, + reason: messageType, + changedAt: Date.now(), + ...(projectName ? { projectName } : {}), + }; +} + /** * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface */ @@ -1372,9 +1501,18 @@ class WebSocketWriter { send(data) { if (this.ws.readyState === 1) { // WebSocket.OPEN - // Providers send raw objects, we stringify for WebSocket - this.ws.send(JSON.stringify(data)); - trackAgentResponseTelemetry(data, this.telemetryContext); + const outboundData = enrichSessionEventPayload(data, this.projectPath); + this.ws.send(JSON.stringify(outboundData)); + trackAgentResponseTelemetry(outboundData, this.telemetryContext); + + const lifecycle = buildLifecycleMessageFromPayload( + outboundData, + this.telemetryContext?.provider || null, + resolveProjectName(outboundData?.projectName, this.projectPath), + ); + if (lifecycle) { + this.ws.send(JSON.stringify(lifecycle)); + } } } @@ -1519,10 +1657,87 @@ function handleChatConnection(ws, request) { // Wrap WebSocket with writer for consistent interface with SSEStreamWriter const writer = new WebSocketWriter(ws, telemetryContext); + const sendSessionStateChanged = ({ + provider, + sessionId = null, + state, + reason = null, + projectPath = null, + projectName = null, + }) => { + const resolvedProjectName = resolveProjectName(projectName, projectPath || writer.getProjectPath() || null); + writer.send({ + type: 'session-state-changed', + provider, + sessionId, + state, + reason, + changedAt: Date.now(), + ...(resolvedProjectName ? { projectName: resolvedProjectName } : {}), + }); + }; + + const sendSessionAccepted = ({ + provider, + sessionId = null, + requestType, + projectPath = null, + projectName = null, + }) => { + const resolvedProjectName = resolveProjectName(projectName, projectPath || writer.getProjectPath() || null); + writer.send({ + type: 'session-accepted', + provider, + sessionId, + requestType, + acceptedAt: Date.now(), + ...(resolvedProjectName ? { projectName: resolvedProjectName } : {}), + }); + // Keep these writes adjacent and synchronous so clients always see + // `session-accepted` before the corresponding `running` state transition. + sendSessionStateChanged({ + provider, + sessionId, + state: 'running', + reason: 'command-accepted', + projectPath, + projectName: resolvedProjectName, + }); + }; + + const sendSessionBusy = ({ + provider, + sessionId = null, + requestType, + projectPath = null, + projectName = null, + }) => { + const resolvedProjectName = resolveProjectName(projectName, projectPath || writer.getProjectPath() || null); + writer.send({ + type: 'session-busy', + provider, + sessionId, + requestType, + projectPath, + isProcessing: true, + reason: 'session-already-active', + message: 'Session is already running. Wait for completion or stop it before sending another command.', + reportedAt: Date.now(), + ...(resolvedProjectName ? { projectName: resolvedProjectName } : {}), + }); + sendSessionStateChanged({ + provider, + sessionId, + state: 'running', + reason: 'session-already-active', + projectPath, + projectName: resolvedProjectName, + }); + }; + ws.on('message', async (message) => { try { const data = JSON.parse(message); - console.log(`[DEBUG] Received WebSocket message: ${data.type}`); if (data.type === 'telemetry-settings') { const enabled = data.enabled !== false; @@ -1553,8 +1768,21 @@ function handleChatConnection(ws, request) { const sessionId = data.options?.sessionId || data.sessionId; if (sessionId && isClaudeSDKSessionActive(sessionId)) { console.log(`[WARN] Session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'claude', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } + + sendSessionAccepted({ + provider: 'claude', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); queryClaudeSDK(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Claude query error:', error); @@ -1569,6 +1797,12 @@ function handleChatConnection(ws, request) { if (sessionId && isCursorSessionActive(sessionId)) { console.log(`[WARN] Cursor session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'cursor', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1585,6 +1819,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'cursor', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'cursor', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); spawnCursor(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Cursor spawn error:', error); }); @@ -1598,6 +1838,12 @@ function handleChatConnection(ws, request) { if (sessionId && isCodexSessionActive(sessionId)) { console.log(`[WARN] Codex session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'codex', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1614,6 +1860,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'codex', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'codex', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); queryCodex(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Codex query error:', error); }); @@ -1627,6 +1879,12 @@ function handleChatConnection(ws, request) { if (sessionId && isGeminiSessionActive(sessionId)) { console.log(`[WARN] Gemini session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'gemini', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1643,6 +1901,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'gemini', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'gemini', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); spawnGemini(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Gemini spawn error:', error); }); @@ -1656,6 +1920,12 @@ function handleChatConnection(ws, request) { if (sessionId && isOpenRouterSessionActive(sessionId)) { console.log(`[WARN] OpenRouter session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'openrouter', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1672,6 +1942,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'openrouter', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'openrouter', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); queryOpenRouter(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] OpenRouter query error:', error); }); @@ -1686,6 +1962,12 @@ function handleChatConnection(ws, request) { if (sessionId && isLocalGPUSessionActive(sessionId)) { console.log(`[WARN] Local GPU session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'local', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1702,6 +1984,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'local', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'local', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); queryLocalGPU(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Local GPU query error:', error); }); @@ -1715,6 +2003,12 @@ function handleChatConnection(ws, request) { if (sessionId && isNanoClaudeCodeSessionActive(sessionId)) { console.log(`[WARN] Nano Claude Code session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'nano', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } @@ -1731,6 +2025,12 @@ function handleChatConnection(ws, request) { ); writer.telemetryContext = { ...telemetryContext, provider: 'nano', telemetryEnabled: commandTelemetryEnabled }; writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'nano', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); spawnNanoClaudeCode(data.command, { ...data.options, env: sessionEnv }, writer).catch(error => { console.error('[ERROR] Nano Claude Code error:', error); }); @@ -1741,9 +2041,22 @@ function handleChatConnection(ws, request) { if (sessionId && isCursorSessionActive(sessionId)) { console.log(`[WARN] Cursor session ${sessionId} is already active. Ignoring concurrent request.`); + sendSessionBusy({ + provider: 'cursor', + sessionId, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); return; } - + + writer.setProjectPath(data.options?.projectPath || data.options?.cwd || null); + sendSessionAccepted({ + provider: 'cursor', + sessionId: data.sessionId || null, + requestType: data.type, + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); spawnCursor('', { sessionId: data.sessionId, resume: true, @@ -1780,6 +2093,13 @@ function handleChatConnection(ws, request) { provider, success }); + sendSessionStateChanged({ + provider, + sessionId: data.sessionId, + state: success ? 'aborted' : 'running', + reason: success ? 'session-aborted' : 'abort-failed', + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); } else if (data.type === 'claude-permission-response') { // Relay UI approval decisions back into the SDK control flow. // This does not persist permissions; it only resolves the in-flight request, @@ -1801,6 +2121,13 @@ function handleChatConnection(ws, request) { provider: 'cursor', success }); + sendSessionStateChanged({ + provider: 'cursor', + sessionId: data.sessionId, + state: success ? 'aborted' : 'running', + reason: success ? 'session-aborted' : 'abort-failed', + projectPath: data.options?.projectPath || data.options?.cwd || null, + }); } else if (data.type === 'check-session-status') { // Check if a specific session is currently processing const provider = data.provider || 'claude'; @@ -2728,28 +3055,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica // Handle Codex sessions if (provider === 'codex') { - const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); - - // Find the session file by searching for the session ID - const findSessionFile = async (dir) => { - try { - const entries = await fsPromises.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - const found = await findSessionFile(fullPath); - if (found) return found; - } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) { - return fullPath; - } - } - } catch (error) { - // Skip directories we can't read - } - return null; - }; - - const sessionFilePath = await findSessionFile(codexSessionsDir); + const sessionFilePath = await resolveCodexSessionFilePath(safeSessionId); if (!sessionFilePath) { return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId }); diff --git a/server/openai-codex.js b/server/openai-codex.js index 7e7793a0..666e886d 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -23,6 +23,7 @@ import { classifyError, classifySDKError } from '../shared/errorClassifier.js'; import { buildTempAttachmentFilename } from './utils/imageAttachmentFiles.js'; import { buildCodexRealtimeTokenBudget } from './utils/sessionTokenUsage.js'; import { expandSkillCommand } from './utils/skillExpander.js'; +import { buildCodexSessionCreatedEvent } from './utils/codexSessionEvents.js'; // Track active sessions const activeCodexSessions = new Map(); @@ -437,12 +438,11 @@ export async function queryCodex(command, options = {}, ws) { }); } - sendMessage(ws, { - type: 'session-created', + sendMessage(ws, buildCodexSessionCreatedEvent({ sessionId: currentSessionId, - provider: 'codex', - mode: sessionMode || 'research' - }); + sessionMode: sessionMode || 'research', + projectName: workingDirectory ? encodeProjectPath(workingDirectory) : null, + })); }; publishSessionId(thread.id || sessionId || null); diff --git a/server/projects.js b/server/projects.js index 776e0ff7..f5afa5a0 100755 --- a/server/projects.js +++ b/server/projects.js @@ -9,7 +9,8 @@ * 1. **Claude Projects** (stored in ~/.claude/projects/) * - Each project is a directory named with the project path encoded (/ replaced with -) * - Contains .jsonl files with conversation history including 'cwd' field - * - Project metadata stored in ~/.claude/project-config.json + * - Project metadata stored in ~/.dr-claw/project-config.json + * (with one-time fallback migration from ~/.claude/project-config.json) * * 2. **Cursor Projects** (stored in ~/.cursor/chats/) * - Each project directory is named with MD5 hash of the absolute project path @@ -32,7 +33,7 @@ * * 3. **Manual Project Addition**: * - Users can manually add project paths via UI - * - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag + * - Stored in ~/.dr-claw/project-config.json with 'manuallyAdded' flag * - Allows discovering Cursor sessions for projects without Claude sessions * * ## Critical Limitations @@ -46,7 +47,7 @@ * * ## Error Handling * - * - Missing ~/.claude directory is handled gracefully with automatic creation + * - Missing project config directory is handled gracefully with automatic creation * - ENOENT errors are caught and handled without crashing * - Empty arrays returned when no projects/sessions exist * @@ -93,11 +94,90 @@ const PROJECT_PIPELINE_FOLDERS = ['Survey', 'Ideation', 'Experiment', 'Publicati const LEGACY_DEFAULT_WORKSPACES_ROOT = path.join(os.homedir(), 'vibelab'); const CURRENT_DEFAULT_WORKSPACES_ROOT = path.join(os.homedir(), 'dr-claw'); const DELETED_PROJECTS_CONFIG_KEY = '_deletedProjects'; +const PROJECT_CONFIG_FILENAME = 'project-config.json'; +const CURRENT_PROJECT_CONFIG_DIR = path.join(os.homedir(), '.dr-claw'); +const LEGACY_PROJECT_CONFIG_DIR = path.join(os.homedir(), '.claude'); +const CURRENT_PROJECT_CONFIG_PATH = path.join(CURRENT_PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME); +const LEGACY_PROJECT_CONFIG_PATH = path.join(LEGACY_PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME); let projectConfigMutationQueue = Promise.resolve(); const _lastBootstrapByUser = new Map(); // userId -> timestamp const BOOTSTRAP_STALENESS_MS = 60_000; // Only re-scan legacy sources every 60 seconds +function decodeUtf16BeBuffer(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length === 0) { + return ''; + } + + const swapped = Buffer.allocUnsafe(buffer.length); + for (let index = 0; index < buffer.length - 1; index += 2) { + swapped[index] = buffer[index + 1]; + swapped[index + 1] = buffer[index]; + } + if (buffer.length % 2 === 1) { + swapped[buffer.length - 1] = 0x00; + } + return swapped.toString('utf16le'); +} + +function parseJsonAllowBom(rawText) { + const candidates = []; + + if (Buffer.isBuffer(rawText)) { + if (rawText.length >= 2) { + if (rawText[0] === 0xFF && rawText[1] === 0xFE) { + candidates.push(rawText.slice(2).toString('utf16le')); + } else if (rawText[0] === 0xFE && rawText[1] === 0xFF) { + candidates.push(decodeUtf16BeBuffer(rawText.slice(2))); + } + } + + candidates.push(rawText.toString('utf8')); + + const sampleLength = Math.min(rawText.length, 256); + let evenZeroCount = 0; + let oddZeroCount = 0; + for (let index = 0; index < sampleLength; index += 1) { + if (rawText[index] === 0x00) { + if (index % 2 === 0) { + evenZeroCount += 1; + } else { + oddZeroCount += 1; + } + } + } + + if (oddZeroCount > sampleLength * 0.2 && evenZeroCount < sampleLength * 0.05) { + candidates.push(rawText.toString('utf16le')); + } + if (evenZeroCount > sampleLength * 0.2 && oddZeroCount < sampleLength * 0.05) { + candidates.push(decodeUtf16BeBuffer(rawText)); + } + } else if (typeof rawText === 'string') { + candidates.push(rawText); + } else { + candidates.push(String(rawText ?? '')); + } + + let lastError = null; + const seen = new Set(); + for (const candidate of candidates) { + const normalized = String(candidate).replace(/^\uFEFF+/, '').replace(/\u0000/g, ''); + const dedupeKey = normalized.slice(0, 512); + if (seen.has(dedupeKey)) { + continue; + } + seen.add(dedupeKey); + try { + return JSON.parse(normalized); + } catch (error) { + lastError = error; + } + } + + throw lastError || new Error('Invalid JSON content'); +} + function isProjectTrashed(projectInfo = null, dbEntry = null) { return Boolean(projectInfo?.trash?.trashedAt || dbEntry?.metadata?.trash?.trashedAt); } @@ -118,6 +198,42 @@ function getProjectOwnerUserId(projectInfo = null, dbEntry = null) { ?? null; } +function normalizeUserIdCandidate(userId) { + if (userId === null || userId === undefined || userId === '') { + return null; + } + + const parsed = Number(userId); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +async function resolveValidProjectOwnerUserId( + projectInfo = null, + dbEntry = null, + fallbackUserId = null, +) { + const { userDb } = await import('./database/db.js'); + + const candidateOwnerId = normalizeUserIdCandidate( + getProjectOwnerUserId(projectInfo, dbEntry) ?? fallbackUserId, + ); + if (candidateOwnerId && userDb.getUserById(candidateOwnerId)) { + return candidateOwnerId; + } + + const normalizedFallbackUserId = normalizeUserIdCandidate(fallbackUserId); + if (normalizedFallbackUserId && userDb.getUserById(normalizedFallbackUserId)) { + return normalizedFallbackUserId; + } + + const firstUser = userDb.getFirstUser(); + const firstUserId = normalizeUserIdCandidate(firstUser?.id); + return firstUserId; +} + function getDeletedProjectsStore(config) { if (!config[DELETED_PROJECTS_CONFIG_KEY] || typeof config[DELETED_PROJECTS_CONFIG_KEY] !== 'object') { config[DELETED_PROJECTS_CONFIG_KEY] = {}; @@ -143,8 +259,8 @@ async function readProjectInstanceId(projectPath) { } try { - const instanceRaw = await fs.readFile(path.join(projectPath, 'instance.json'), 'utf8'); - const instanceData = JSON.parse(instanceRaw); + const instanceRaw = await fs.readFile(path.join(projectPath, 'instance.json')); + const instanceData = parseJsonAllowBom(instanceRaw); return typeof instanceData?.instance_id === 'string' && instanceData.instance_id.trim() ? instanceData.instance_id.trim() : null; @@ -217,7 +333,7 @@ async function bootstrapProjectsIndexFromLegacySources(config, projectDb, userId } const existing = projectDb.getProjectById(projectName); - const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(projectInfo, existing) ?? userId ?? null; + const ownerUserId = await resolveValidProjectOwnerUserId(projectInfo, existing, userId); const metadata = { ...(existing?.metadata || {}) }; if (isManuallyAdded) { @@ -292,6 +408,79 @@ function collectCodexProjectCandidates(sessionsByProject = new Map()) { const CODEX_SYNC_COOLDOWN_MS = 30_000; let lastCodexSyncTimestamp = 0; +const CODEX_SESSION_FILE_PATH_CACHE = new Map(); // sessionId -> absolute jsonl path +const CODEX_SESSIONS_INDEX_CACHE_TTL_MS = 10_000; +let codexSessionsIndexCache = null; +let codexSessionsIndexPromise = null; + +function normalizeCodexSessionId(sessionId) { + return typeof sessionId === 'string' ? sessionId.trim() : ''; +} + +function isTemporaryCodexSessionId(sessionId) { + const normalizedSessionId = normalizeCodexSessionId(sessionId); + if (!normalizedSessionId) { + return false; + } + return normalizedSessionId.startsWith('new-session-') || normalizedSessionId.startsWith('temp-'); +} + +function rememberCodexSessionFilePath(sessionId, filePath) { + const normalizedSessionId = normalizeCodexSessionId(sessionId); + if (!normalizedSessionId || !filePath) { + return; + } + CODEX_SESSION_FILE_PATH_CACHE.set(normalizedSessionId, filePath); +} + +function clearCachedCodexSessionFilePath(sessionId, filePath = null) { + const normalizedSessionId = normalizeCodexSessionId(sessionId); + if (!normalizedSessionId) { + return; + } + + const cachedPath = CODEX_SESSION_FILE_PATH_CACHE.get(normalizedSessionId); + if (filePath && cachedPath && cachedPath !== filePath) { + return; + } + CODEX_SESSION_FILE_PATH_CACHE.delete(normalizedSessionId); +} + +function readCachedCodexSessionFilePath(sessionId) { + const normalizedSessionId = normalizeCodexSessionId(sessionId); + if (!normalizedSessionId) { + return null; + } + + const cachedPath = CODEX_SESSION_FILE_PATH_CACHE.get(normalizedSessionId); + if (!cachedPath) { + return null; + } + + if (fsSync.existsSync(cachedPath)) { + return cachedPath; + } + + CODEX_SESSION_FILE_PATH_CACHE.delete(normalizedSessionId); + return null; +} + +function invalidateCodexSessionsIndexCache() { + codexSessionsIndexCache = null; +} + +function readCachedCodexSessionsIndex() { + if (!codexSessionsIndexCache?.sessionsByProject) { + return null; + } + + if ((Date.now() - codexSessionsIndexCache.builtAt) > CODEX_SESSIONS_INDEX_CACHE_TTL_MS) { + codexSessionsIndexCache = null; + return null; + } + + return codexSessionsIndexCache.sessionsByProject; +} async function syncDiscoveredProjectsFromCodexSessions(config, projectDb, userId = null, visibleWorkspaceRoots = []) { const now = Date.now(); @@ -321,7 +510,7 @@ async function syncDiscoveredProjectsFromCodexSessions(config, projectDb, userId continue; } - const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(projectInfo, existing) ?? userId ?? null; + const ownerUserId = await resolveValidProjectOwnerUserId(projectInfo, existing, userId); const metadata = { ...(existing?.metadata || {}) }; if (projectInfo?.trash?.trashedAt) { @@ -463,8 +652,8 @@ async function detectTaskMasterFolder(projectPath) { if (fileStatus['tasks/tasks.json']) { try { const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json'); - const tasksContent = await fs.readFile(tasksPath, 'utf8'); - const tasksData = JSON.parse(tasksContent); + const tasksContent = await fs.readFile(tasksPath); + const tasksData = parseJsonAllowBom(tasksContent); // Handle both tagged and legacy formats let tasks = []; @@ -550,12 +739,31 @@ function clearProjectDirectoryCache() { projectDirectoryCache.clear(); } +async function resolveProjectConfigPath() { + if (fsSync.existsSync(CURRENT_PROJECT_CONFIG_PATH)) { + return CURRENT_PROJECT_CONFIG_PATH; + } + + if (!fsSync.existsSync(LEGACY_PROJECT_CONFIG_PATH)) { + return CURRENT_PROJECT_CONFIG_PATH; + } + + try { + await fs.mkdir(CURRENT_PROJECT_CONFIG_DIR, { recursive: true }); + await fs.copyFile(LEGACY_PROJECT_CONFIG_PATH, CURRENT_PROJECT_CONFIG_PATH); + return CURRENT_PROJECT_CONFIG_PATH; + } catch (error) { + console.warn('[projects] Failed to migrate legacy project config, using legacy path:', error.message); + return LEGACY_PROJECT_CONFIG_PATH; + } +} + // Load project configuration file async function loadProjectConfig() { - const configPath = path.join(os.homedir(), '.claude', 'project-config.json'); + const configPath = await resolveProjectConfigPath(); try { - const configData = await fs.readFile(configPath, 'utf8'); - return JSON.parse(configData); + const configData = await fs.readFile(configPath); + return parseJsonAllowBom(configData); } catch (error) { // Return empty config if file doesn't exist return {}; @@ -783,12 +991,11 @@ async function migrateLegacyProjects(config, projectDb) { // Save project configuration file async function saveProjectConfig(config) { - const claudeDir = path.join(os.homedir(), '.claude'); - const configPath = path.join(claudeDir, 'project-config.json'); + const configPath = CURRENT_PROJECT_CONFIG_PATH; - // Ensure the .claude directory exists + // Ensure the .dr-claw directory exists try { - await fs.mkdir(claudeDir, { recursive: true }); + await fs.mkdir(CURRENT_PROJECT_CONFIG_DIR, { recursive: true }); } catch (error) { if (error.code !== 'EEXIST') { throw error; @@ -1459,7 +1666,7 @@ async function getTrashedProjects(userId = null) { continue; } - const ownerUserId = getProjectOwnerUserId(projectInfo, dbEntry); + const ownerUserId = await resolveValidProjectOwnerUserId(projectInfo, dbEntry, userId); if (userId && ownerUserId !== userId) { continue; } @@ -2606,7 +2813,7 @@ async function deleteProject(projectName, force = false, userId = null) { const existing = projectDb.getProjectById(projectName); const initialConfig = await loadProjectConfig(); const initialProjectInfo = initialConfig[projectName]; - const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(initialProjectInfo, existing) ?? userId ?? null; + const ownerUserId = await resolveValidProjectOwnerUserId(initialProjectInfo, existing, userId); if (userId && ownerUserId && ownerUserId !== userId) { throw new Error('You do not have permission to delete this project'); @@ -2709,7 +2916,7 @@ async function restoreProject(projectName, userId = null) { const config = await loadProjectConfig(); const existing = projectDb.getProjectById(projectName); const projectInfo = config[projectName]; - const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(projectInfo, existing) ?? userId ?? null; + const ownerUserId = await resolveValidProjectOwnerUserId(projectInfo, existing, userId); if (userId && ownerUserId && ownerUserId !== userId) { throw new Error('You do not have permission to restore this project'); @@ -2764,7 +2971,7 @@ async function deleteTrashedProject(projectName, mode = 'logical', userId = null const config = await loadProjectConfig(); const existing = projectDb.getProjectById(projectName); const projectInfo = config[projectName]; - const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(projectInfo, existing) ?? userId ?? null; + const ownerUserId = await resolveValidProjectOwnerUserId(projectInfo, existing, userId); if (userId && ownerUserId && ownerUserId !== userId) { throw new Error('You do not have permission to delete this trashed project'); @@ -3403,6 +3610,12 @@ async function getGeminiSessions(projectPath, optionsOrUserId = null) { ? dedupedSessions.filter((session) => session.id === targetSessionId) : dedupedSessions; + for (const session of filteredSessions) { + if (session?.id && session?.filePath) { + rememberCodexSessionFilePath(session.id, session.filePath); + } + } + if (syncIndex) { const { sessionDb } = await import('./database/db.js'); const projectName = providedProjectName || encodeProjectPath(projectPath); @@ -3618,57 +3831,93 @@ async function findCodexJsonlFiles(dir) { return files; } -async function buildCodexSessionsIndex() { - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - const sessionsByProject = new Map(); +async function buildCodexSessionsIndex(options = {}) { + const { forceRefresh = false } = options; + if (!forceRefresh) { + const cachedIndex = readCachedCodexSessionsIndex(); + if (cachedIndex) { + return cachedIndex; + } + } else { + invalidateCodexSessionsIndexCache(); + } - try { - await fs.access(codexSessionsDir); - } catch (error) { - return sessionsByProject; + if (codexSessionsIndexPromise) { + return codexSessionsIndexPromise; } - const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); + const buildPromise = (async () => { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + const sessionsByProject = new Map(); - for (const filePath of jsonlFiles) { try { - const sessionData = await parseCodexSessionFile(filePath); - if (!sessionData || !sessionData.id) { - continue; - } + await fs.access(codexSessionsDir); + } catch (error) { + codexSessionsIndexCache = { + builtAt: Date.now(), + sessionsByProject, + }; + return sessionsByProject; + } - const normalizedProjectPath = await normalizeComparablePath(sessionData.cwd); - if (!normalizedProjectPath) { - continue; - } + const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); - const session = { - id: sessionData.id, - summary: sessionData.summary || 'Codex Session', - messageCount: sessionData.messageCount || 0, - lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), - cwd: sessionData.cwd, - model: sessionData.model, - mode: normalizeSessionMode(sessionData.mode), - filePath, - provider: 'codex', - }; + for (const filePath of jsonlFiles) { + try { + const sessionData = await parseCodexSessionFile(filePath); + if (!sessionData || !sessionData.id) { + continue; + } + + const normalizedProjectPath = await normalizeComparablePath(sessionData.cwd); + if (!normalizedProjectPath) { + continue; + } - if (!sessionsByProject.has(normalizedProjectPath)) { - sessionsByProject.set(normalizedProjectPath, []); + const session = { + id: sessionData.id, + summary: sessionData.summary || 'Codex Session', + messageCount: sessionData.messageCount || 0, + lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), + cwd: sessionData.cwd, + model: sessionData.model, + mode: normalizeSessionMode(sessionData.mode), + filePath, + provider: 'codex', + }; + + rememberCodexSessionFilePath(session.id, filePath); + + if (!sessionsByProject.has(normalizedProjectPath)) { + sessionsByProject.set(normalizedProjectPath, []); + } + + sessionsByProject.get(normalizedProjectPath).push(session); + } catch (error) { + console.warn(`Could not parse Codex session file ${filePath}:`, error.message); } + } - sessionsByProject.get(normalizedProjectPath).push(session); - } catch (error) { - console.warn(`Could not parse Codex session file ${filePath}:`, error.message); + for (const sessions of sessionsByProject.values()) { + sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); } - } - for (const sessions of sessionsByProject.values()) { - sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); - } + codexSessionsIndexCache = { + builtAt: Date.now(), + sessionsByProject, + }; - return sessionsByProject; + return sessionsByProject; + })(); + + codexSessionsIndexPromise = buildPromise; + try { + return await buildPromise; + } finally { + if (codexSessionsIndexPromise === buildPromise) { + codexSessionsIndexPromise = null; + } + } } // Fetch Codex sessions for a given project path @@ -3841,40 +4090,64 @@ function isCodexSystemPromptContent(text) { return false; } -// Get messages for a specific Codex session -async function getCodexSessionMessages(sessionId, limit = null, offset = 0) { - try { - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); +async function resolveCodexSessionFilePath(sessionId) { + const normalizedSessionId = normalizeCodexSessionId(sessionId); + if (!normalizedSessionId || isTemporaryCodexSessionId(normalizedSessionId)) { + return null; + } - const findSessionFileByMetadata = async () => { - const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); + const cachedPath = readCachedCodexSessionFilePath(normalizedSessionId); + if (cachedPath) { + return cachedPath; + } - let filenameMatch = null; - for (const filePath of jsonlFiles) { - if (path.basename(filePath).includes(sessionId)) { - filenameMatch = filePath; - break; - } - } + // Warm index-level cache once so repeated session switches do not rescan the tree. + await buildCodexSessionsIndex(); + const indexedCachedPath = readCachedCodexSessionFilePath(normalizedSessionId); + if (indexedCachedPath) { + return indexedCachedPath; + } - if (filenameMatch) { - return filenameMatch; - } + // If cache is stale, force one refresh before falling back to per-file probing. + await buildCodexSessionsIndex({ forceRefresh: true }); + const refreshedCachedPath = readCachedCodexSessionFilePath(normalizedSessionId); + if (refreshedCachedPath) { + return refreshedCachedPath; + } - for (const filePath of jsonlFiles) { - const sessionData = await parseCodexSessionFile(filePath); - if (sessionData?.id === sessionId) { - return filePath; - } - } + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); - return null; - }; + for (const filePath of jsonlFiles) { + if (path.basename(filePath).includes(normalizedSessionId)) { + rememberCodexSessionFilePath(normalizedSessionId, filePath); + return filePath; + } + } - const sessionFilePath = await findSessionFileByMetadata(); + for (const filePath of jsonlFiles) { + const sessionData = await parseCodexSessionFile(filePath); + if (sessionData?.id === normalizedSessionId) { + rememberCodexSessionFilePath(normalizedSessionId, filePath); + return filePath; + } + } + + return null; +} + +// Get messages for a specific Codex session +async function getCodexSessionMessages(sessionId, limit = null, offset = 0) { + try { + const normalizedSessionId = normalizeCodexSessionId(sessionId); + if (!normalizedSessionId || isTemporaryCodexSessionId(normalizedSessionId)) { + return { messages: [], total: 0, hasMore: false }; + } + + const sessionFilePath = await resolveCodexSessionFilePath(normalizedSessionId); if (!sessionFilePath) { - console.warn(`Codex session file not found for session ${sessionId}`); + console.warn(`Codex session file not found for session ${normalizedSessionId}`); return { messages: [], total: 0, hasMore: false }; } @@ -4110,6 +4383,9 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) { return { messages, tokenUsage }; } catch (error) { + if (error?.code === 'ENOENT') { + clearCachedCodexSessionFilePath(sessionId); + } console.error(`Error reading Codex session messages for ${sessionId}:`, error); return { messages: [], total: 0, hasMore: false }; } @@ -4120,6 +4396,7 @@ async function deleteCodexSession(sessionId) { const { sessionDb } = await import('./database/db.js'); const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); const indexedSession = sessionDb.getSessionById(sessionId); + const normalizedSessionId = normalizeCodexSessionId(sessionId); const findJsonlFiles = async (dir) => { const files = []; @@ -4144,6 +4421,8 @@ async function deleteCodexSession(sessionId) { const sessionData = await parseCodexSessionFile(filePath); if (sessionData && sessionData.id === sessionId) { await fs.unlink(filePath); + clearCachedCodexSessionFilePath(normalizedSessionId, filePath); + invalidateCodexSessionsIndexCache(); deletedFile = true; break; } @@ -4154,6 +4433,8 @@ async function deleteCodexSession(sessionId) { if (deletedIndex) { sessionDb.deleteSession(sessionId); + clearCachedCodexSessionFilePath(normalizedSessionId); + invalidateCodexSessionsIndexCache(); } if (deletedFile || deletedIndex) { @@ -4351,6 +4632,7 @@ export { getSessions, getSessionMessages, collectCodexProjectCandidates, + resolveValidProjectOwnerUserId, parseJsonlSessions, renameProject, renameSession, @@ -4367,6 +4649,7 @@ export { getCodexSessions, getGeminiSessions, getCodexSessionMessages, + resolveCodexSessionFilePath, deleteCodexSession, reconcileClaudeSessionIndex, reconcileCodexSessionIndex, diff --git a/server/utils/__tests__/safePath.test.js b/server/utils/__tests__/safePath.test.js index 40ab9f8c..649d1c1b 100644 --- a/server/utils/__tests__/safePath.test.js +++ b/server/utils/__tests__/safePath.test.js @@ -58,13 +58,13 @@ describe('safePath', () => { }); it('handles non-existent target gracefully', () => { - // Non-existent file in existing directory — should work + // Non-existent file in existing directory ?should work const result = safePath('src/newfile.js', ROOT); expect(result).toBe(path.join(ROOT, 'src', 'newfile.js')); }); it('handles non-existent nested path gracefully', () => { - // Non-existent nested path — should still resolve within root + // Non-existent nested path ?should still resolve within root const result = safePath('deep/nested/new/file.js', ROOT); expect(result.startsWith(ROOT + path.sep)).toBe(true); }); @@ -78,16 +78,29 @@ describe('safePath', () => { }); it('allows symlinks inside the project that point outside the root', () => { - // Simulate: project/data -> /tmp (an external location) - // This should NOT be blocked — legitimate workflow (shared datasets, etc.) + // Simulate: project/data -> /tmp (an external location). + // This should not be blocked (legitimate workflow: shared datasets, etc.). const linkPath = path.join(ROOT, 'external-data'); + let created = false; try { fs.symlinkSync(os.tmpdir(), linkPath); + created = true; // Logical path is inside root, so safePath should allow it const result = safePath('external-data/some-file.csv', ROOT); expect(result).toBe(path.join(ROOT, 'external-data', 'some-file.csv')); + } catch (error) { + // Some Windows environments deny symlink creation without admin/dev mode. + const code = typeof error?.code === 'string' ? error.code : ''; + if (code === 'EPERM' || code === 'EACCES') { + expect(true).toBe(true); + return; + } + throw error; } finally { - try { fs.unlinkSync(linkPath); } catch { /* ignore cleanup errors */ } + if (created) { + try { fs.unlinkSync(linkPath); } catch { /* ignore cleanup errors */ } + } } }); }); + diff --git a/server/utils/codexSessionEvents.js b/server/utils/codexSessionEvents.js new file mode 100644 index 00000000..0fe4a816 --- /dev/null +++ b/server/utils/codexSessionEvents.js @@ -0,0 +1,13 @@ +export function buildCodexSessionCreatedEvent({ + sessionId, + sessionMode = 'research', + projectName = null, +}) { + return { + type: 'session-created', + sessionId, + provider: 'codex', + mode: sessionMode || 'research', + ...(projectName ? { projectName } : {}), + }; +} diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 1b2a01e3..2b6e7955 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, ClipboardEvent, @@ -8,24 +8,42 @@ import type { MouseEvent, SetStateAction, TouchEvent, -} from 'react'; -import { useDropzone } from 'react-dropzone'; -import type { FileRejection } from 'react-dropzone'; -import { useTranslation } from 'react-i18next'; -import { authenticatedFetch } from '../../../utils/api'; -import { isTelemetryEnabled } from '../../../utils/telemetry'; - -import { thinkingModes } from '../constants/thinkingModes'; -import type { CodexReasoningEffortId } from '../constants/codexReasoningEfforts'; -import { getSupportedCodexReasoningEfforts } from '../constants/codexReasoningSupport'; -import type { GeminiThinkingModeId } from '../../../../shared/geminiThinkingSupport'; -import { getSupportedGeminiThinkingModes } from '../../../../shared/geminiThinkingSupport'; - -import { grantToolPermission } from '../utils/chatPermissions'; -import { clearSessionTimerStart, getProviderSettingsKey, persistSessionTimerStart, safeLocalStorage } from '../utils/chatStorage'; -import { consumeWorkspaceQaDraft, WORKSPACE_QA_DRAFT_EVENT } from '../../../utils/workspaceQa'; -import { consumeReferenceChatDraft, REFERENCE_CHAT_DRAFT_EVENT } from '../../../utils/referenceChatDraft'; -import { consumeSkillCommandDraft, SKILL_COMMAND_DRAFT_EVENT } from '../../../utils/skillCommandDraft'; +} from "react"; +import { useDropzone } from "react-dropzone"; +import type { FileRejection } from "react-dropzone"; +import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { authenticatedFetch } from "../../../utils/api"; +import { isTelemetryEnabled } from "../../../utils/telemetry"; + +import { thinkingModes } from "../constants/thinkingModes"; +import type { CodexReasoningEffortId } from "../constants/codexReasoningEfforts"; +import { getSupportedCodexReasoningEfforts } from "../constants/codexReasoningSupport"; +import type { GeminiThinkingModeId } from "../../../../shared/geminiThinkingSupport"; +import { getSupportedGeminiThinkingModes } from "../../../../shared/geminiThinkingSupport"; + +import { grantToolPermission } from "../utils/chatPermissions"; +import { + buildDraftInputStorageKey, + clearScopedPendingSessionId, + clearScopedProviderSessionId, + clearSessionTimerStart, + getProviderSettingsKey, + persistScopedPendingSessionId, + persistScopedProviderSessionId, + persistSessionTimerStart, + readScopedPendingSessionId, + readScopedProviderSessionId, + safeLocalStorage, +} from "../utils/chatStorage"; +import { + consumeWorkspaceQaDraft, + WORKSPACE_QA_DRAFT_EVENT, +} from "../../../utils/workspaceQa"; +import { + consumeReferenceChatDraft, + REFERENCE_CHAT_DRAFT_EVENT, +} from "../../../utils/referenceChatDraft"; import type { AttachedPrompt, ChatAttachment, @@ -33,29 +51,54 @@ import type { ChatMessage, PendingPermissionRequest, PermissionMode, + QueuedTurn, + QueuedTurnKind, + QueuedTurnStatus, TokenBudget, -} from '../types/types'; -import { useFileMentions } from './useFileMentions'; -import { type SlashCommand, useSlashCommands } from './useSlashCommands'; -import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; -import { escapeRegExp } from '../utils/chatFormatting'; -import { isAutoResearchScenario } from '../utils/autoResearch'; -import type { SessionMode } from '../../../types/app'; -import type { BtwOverlayState } from '../view/subcomponents/BtwOverlay'; - -const CLOSED_BTW_OVERLAY: BtwOverlayState = { - open: false, - question: '', - answer: '', - loading: false, - error: null, -}; +} from "../types/types"; +import { useFileMentions } from "./useFileMentions"; +import { type SlashCommand, useSlashCommands } from "./useSlashCommands"; +import type { + Project, + ProjectSession, + SessionProvider, +} from "../../../types/app"; +import { escapeRegExp } from "../utils/chatFormatting"; +import type { SessionMode } from "../../../types/app"; +import { normalizeProvider } from "../../../utils/providerPolicy"; +import { + buildSessionScopeKey, + parseSessionScopeKey, + scopeKeyMatchesSessionId, +} from "../../../utils/sessionScope"; +import { + buildQueuedTurn, + enqueueSessionTurn, + getNextDispatchableTurn, + getSessionQueue, + promoteQueuedTurnToSteer, + reconcileSessionQueueId, + reconcileSettledSessionQueue, + removeQueuedTurn, + setSessionQueueStatus, + type SessionQueueMap, +} from "../utils/codexQueue"; +import { + OPTIMISTIC_SESSION_CREATED_EVENT, + type OptimisticSessionCreatedDetail, +} from "../../../constants/sessionEvents"; +import type { BtwOverlayState } from "../view/subcomponents/BtwOverlay"; type PendingViewSession = { sessionId: string | null; startedAt: number; }; +type PendingCodexQueueDispatch = { + turn: QueuedTurn; + shouldUpdateForegroundState: boolean; +}; + interface UseChatComposerStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; @@ -75,19 +118,38 @@ interface UseChatComposerStateArgs { tokenBudget: TokenBudget | null; sendMessage: (message: unknown) => void; sendByCtrlEnter?: boolean; - onSessionActive?: (sessionId?: string | null) => void; + onSessionActive?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionProcessing?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; onInputFocusChange?: (focused: boolean) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; + processingSessions?: Set; pendingViewSessionRef: { current: PendingViewSession | null }; scrollToBottom: () => void; setChatMessages: Dispatch>; setSessionMessages?: Dispatch>; setIsLoading: (loading: boolean) => void; setCanAbortSession: (canAbort: boolean) => void; - setClaudeStatus: Dispatch>; + setClaudeStatus: Dispatch< + SetStateAction<{ + text: string; + tokens: number; + can_interrupt: boolean; + startTime?: number; + } | null> + >; setIsUserScrolledUp: (isScrolledUp: boolean) => void; - setPendingPermissionRequests: Dispatch>; + setPendingPermissionRequests: Dispatch< + SetStateAction + >; newSessionMode?: SessionMode; /** Current chat messages for /btw context (Claude provider). */ getChatMessagesForBtw?: () => ChatMessage[]; @@ -99,7 +161,7 @@ interface MentionableFile { } interface CommandExecutionResult { - type: 'builtin' | 'custom'; + type: "builtin" | "custom"; action?: string; data?: any; content?: string; @@ -114,28 +176,39 @@ interface UploadedProjectFile { } const createFakeSubmitEvent = () => { - return { preventDefault: () => undefined } as unknown as FormEvent; + return { + preventDefault: () => undefined, + } as unknown as FormEvent; }; const PROGRAMMATIC_SUBMIT_MAX_RETRIES = 12; const PROGRAMMATIC_SUBMIT_RETRY_DELAY_MS = 50; +const CODEX_QUEUE_DISPATCH_AFTER_SETTLE_MS = 800; +const CODEX_QUEUE_DISPATCH_ACK_TIMEOUT_MS = 6000; const MAX_ATTACHMENTS = 5; const MAX_ATTACHMENT_SIZE_BYTES = 50 * 1024 * 1024; -const CODEX_ATTACHMENT_DIR = '.dr-claw/chat-attachments'; +const CODEX_ATTACHMENT_DIR = ".dr-claw/chat-attachments"; +const CLOSED_BTW_OVERLAY: BtwOverlayState = { + open: false, + question: "", + answer: "", + loading: false, + error: null, +}; const IMAGE_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.bmp', - '.svg', - '.heic', - '.heif', + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".svg", + ".heic", + ".heif", ]); -const PDF_EXTENSION = '.pdf'; +const PDF_EXTENSION = ".pdf"; function getAttachmentKey(file: File) { return `${file.name}:${file.size}:${file.lastModified}`; @@ -143,49 +216,54 @@ function getAttachmentKey(file: File) { function getFileExtension(file: File) { const lowerName = file.name.toLowerCase(); - const lastDot = lowerName.lastIndexOf('.'); - return lastDot >= 0 ? lowerName.slice(lastDot) : ''; + const lastDot = lowerName.lastIndexOf("."); + return lastDot >= 0 ? lowerName.slice(lastDot) : ""; } function isImageAttachment(file: File) { - return file.type.startsWith('image/') || IMAGE_EXTENSIONS.has(getFileExtension(file)); + return ( + file.type.startsWith("image/") || + IMAGE_EXTENSIONS.has(getFileExtension(file)) + ); } function isPdfAttachment(file: File) { - return file.type === 'application/pdf' || getFileExtension(file) === PDF_EXTENSION; + return ( + file.type === "application/pdf" || getFileExtension(file) === PDF_EXTENSION + ); } function getAttachmentKind(file: File) { if (isImageAttachment(file)) { - return 'image'; + return "image"; } if (isPdfAttachment(file)) { - return 'pdf'; + return "pdf"; } - return 'file'; + return "file"; } function formatRejectedFileMessage(rejection: FileRejection) { const attachmentKey = getAttachmentKey(rejection.file); - const name = rejection.file?.name || 'Unknown file'; + const name = rejection.file?.name || "Unknown file"; const messages = rejection.errors.map((error) => { - if (error.code === 'file-too-large') { - return 'File too large (max 50MB)'; + if (error.code === "file-too-large") { + return "File too large (max 50MB)"; } - if (error.code === 'too-many-files') { - return 'Too many files (max 5)'; + if (error.code === "too-many-files") { + return "Too many files (max 5)"; } return error.message; }); return { attachmentKey, - message: `${name}: ${messages.join(', ') || 'File rejected'}`, + message: `${name}: ${messages.join(", ") || "File rejected"}`, }; } const isTemporarySessionId = (sessionId: string | null | undefined) => - Boolean(sessionId && sessionId.startsWith('new-session-')); + Boolean(sessionId && sessionId.startsWith("new-session-")); const BTW_TRANSCRIPT_MAX_CHARS = 120_000; @@ -216,7 +294,7 @@ function buildBtwTranscript(messages: ChatMessage[]): string { } const getRouteSessionId = () => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return null; } @@ -232,6 +310,100 @@ const getRouteSessionId = () => { } }; +const getOptimisticSessionDisplayName = (input: string) => { + const firstLine = input + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + + if (!firstLine) { + return "New Session"; + } + + return firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine; +}; + +const getCodexQueueStorageKey = (projectName: string) => + `codex_queue_${projectName}`; + +const readPersistedCodexQueue = ( + projectName?: string | null, +): SessionQueueMap => { + if (!projectName) { + return {}; + } + + try { + const raw = safeLocalStorage.getItem(getCodexQueueStorageKey(projectName)); + if (!raw) { + return {}; + } + + const parsed = JSON.parse(raw) as Record; + if (!parsed || typeof parsed !== "object") { + return {}; + } + + const normalized: SessionQueueMap = {}; + for (const [sessionId, queue] of Object.entries(parsed)) { + if (!Array.isArray(queue)) { + continue; + } + + const turns: QueuedTurn[] = []; + for (const candidate of queue) { + if (!candidate || typeof candidate !== "object") { + continue; + } + + const turn = { + id: + typeof (candidate as any).id === "string" + ? (candidate as any).id + : `queued-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + sessionId, + text: + typeof (candidate as any).text === "string" + ? (candidate as any).text + : "", + kind: (candidate as any).kind === "steer" ? "steer" : "normal", + status: (candidate as any).status === "paused" ? "paused" : "queued", + createdAt: + typeof (candidate as any).createdAt === "number" + ? (candidate as any).createdAt + : Date.now(), + projectName: + typeof (candidate as any).projectName === "string" + ? (candidate as any).projectName + : undefined, + projectPath: + typeof (candidate as any).projectPath === "string" + ? (candidate as any).projectPath + : undefined, + sessionMode: + (candidate as any).sessionMode === "workspace_qa" + ? "workspace_qa" + : "research", + } satisfies QueuedTurn; + + if (!turn.text.trim()) { + continue; + } + + turns.push(turn); + } + + if (turns.length > 0) { + normalized[sessionId] = turns; + } + } + + return normalized; + } catch { + return {}; + } +}; + export function useChatComposerState({ selectedProject, selectedSession, @@ -252,9 +424,11 @@ export function useChatComposerState({ sendMessage, sendByCtrlEnter, onSessionActive, + onSessionProcessing, onInputFocusChange, onFileOpen, onShowSettings, + processingSessions, pendingViewSessionRef, scrollToBottom, setChatMessages, @@ -264,54 +438,66 @@ export function useChatComposerState({ setClaudeStatus, setIsUserScrolledUp, setPendingPermissionRequests, - newSessionMode = 'research', + newSessionMode = "research", getChatMessagesForBtw, }: UseChatComposerStateArgs) { - const { t } = useTranslation('chat'); + const { t } = useTranslation("chat"); + const { pathname } = useLocation(); + const initialDraftBucket = + selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || "new"; + const initialDraftStorageKey = buildDraftInputStorageKey( + selectedProject?.name || null, + normalizeProvider(provider), + initialDraftBucket, + ); const [input, setInput] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; + if (typeof window !== "undefined" && initialDraftStorageKey) { + return safeLocalStorage.getItem(initialDraftStorageKey) || ""; } - return ''; + return ""; }); const [attachedFiles, setAttachedFiles] = useState([]); - const [uploadingFiles, setUploadingFiles] = useState>(new Map()); + const [uploadingFiles, setUploadingFiles] = useState>( + new Map(), + ); const [fileErrors, setFileErrors] = useState>(new Map()); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); - const [thinkingMode, setThinkingMode] = useState('none'); - const [codexReasoningEffort, setCodexReasoningEffort] = useState(() => { - const savedValue = safeLocalStorage.getItem('codex-reasoning-effort'); - switch (savedValue) { - case 'minimal': - case 'low': - case 'medium': - case 'high': - case 'xhigh': - case 'default': - return savedValue; - default: - return 'default'; + const [thinkingMode, setThinkingMode] = useState("none"); + const [codexReasoningEffort, setCodexReasoningEffort] = + useState(() => { + const savedValue = safeLocalStorage.getItem("codex-reasoning-effort"); + switch (savedValue) { + case "minimal": + case "low": + case "medium": + case "high": + case "xhigh": + case "default": + return savedValue; + default: + return "default"; } - }); - const [geminiThinkingMode, setGeminiThinkingMode] = useState(() => { - const savedValue = safeLocalStorage.getItem('gemini-thinking-mode'); - switch (savedValue) { - case 'default': - case 'minimal': - case 'low': - case 'medium': - case 'high': - case 'dynamic': - case 'off': - case 'light': - case 'balanced': - case 'deep': - case 'max': - return savedValue; - default: - return 'default'; - } - }); + }); + const [geminiThinkingMode, setGeminiThinkingMode] = + useState(() => { + const savedValue = safeLocalStorage.getItem("gemini-thinking-mode"); + switch (savedValue) { + case "default": + case "minimal": + case "low": + case "medium": + case "high": + case "dynamic": + case "off": + case "light": + case "balanced": + case "deep": + case "max": + return savedValue; + default: + return "default"; + } + }); const [intakeGreeting, setIntakeGreeting] = useState(null); const [btwOverlay, setBtwOverlay] = useState(CLOSED_BTW_OVERLAY); const btwAbortRef = useRef(null); @@ -321,15 +507,112 @@ export function useChatComposerState({ setBtwOverlay(CLOSED_BTW_OVERLAY); }, []); const [pendingStageTagKeys, setPendingStageTagKeys] = useState([]); - const [attachedPrompt, setAttachedPrompt] = useState(null); + const [attachedPrompt, setAttachedPrompt] = useState( + null, + ); + const [steerMode, setSteerMode] = useState(false); + const [isQueueBootstrapReady, setIsQueueBootstrapReady] = useState(false); + const [queuedTurnsBySession, setQueuedTurnsBySession] = + useState(() => + readPersistedCodexQueue(selectedProject?.name), + ); const textareaRef = useRef(null); const inputHighlightRef = useRef(null); const handleSubmitRef = useRef< - ((event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent) => Promise) | null + | (( + event: + | FormEvent + | MouseEvent + | TouchEvent + | KeyboardEvent, + ) => Promise) + | null >(null); const inputValueRef = useRef(input); const abortTimeoutRef = useRef | null>(null); + const queuedTurnsBySessionRef = useRef(queuedTurnsBySession); + const queueDispatchLocksRef = useRef>(new Set()); + const pendingQueueDispatchesRef = useRef< + Map + >(new Map()); + const queueDispatchAckTimersRef = useRef< + Map> + >(new Map()); + const codexBusyRejectedDispatchesRef = useRef>(new Set()); + const queueBootstrapTimerRef = useRef | null>( + null, + ); + const lastSubmittedCodexSessionRef = useRef(null); + const forceSteerForSubmitRef = useRef(false); + const normalizedProvider = normalizeProvider(provider); + const currentProjectName = selectedProject?.name || null; + const activeDraftBucket = + selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || "new"; + const draftStorageKey = useMemo( + () => + buildDraftInputStorageKey( + currentProjectName, + normalizedProvider, + activeDraftBucket, + ), + [activeDraftBucket, currentProjectName, normalizedProvider], + ); + + const hasProcessingSession = useCallback( + ( + sessionId: string | null | undefined, + providerOverride: SessionProvider | string | null | undefined = normalizedProvider, + projectNameOverride: string | null | undefined = currentProjectName, + ) => { + if (!sessionId || !processingSessions || !projectNameOverride) { + return false; + } + + const scopeKey = buildSessionScopeKey( + projectNameOverride, + providerOverride || normalizedProvider, + sessionId, + ); + + if (scopeKey && processingSessions.has(scopeKey)) { + return true; + } + + if (processingSessions.has(sessionId)) { + return true; + } + + const normalizedProviderOverride = normalizeProvider( + (providerOverride || normalizedProvider) as SessionProvider, + ); + + return Array.from(processingSessions).some((trackingKey) => { + if (!scopeKeyMatchesSessionId(trackingKey, sessionId)) { + return false; + } + + const parsedScope = parseSessionScopeKey(trackingKey); + if (!parsedScope) { + return trackingKey === sessionId; + } + + return ( + parsedScope.projectName === projectNameOverride && + parsedScope.provider === normalizedProviderOverride + ); + }); + }, + [currentProjectName, normalizedProvider, processingSessions], + ); + + useEffect(() => { + queuedTurnsBySessionRef.current = queuedTurnsBySession; + }, [queuedTurnsBySession]); + + useEffect(() => { + setQueuedTurnsBySession(readPersistedCodexQueue(selectedProject?.name)); + }, [selectedProject?.name]); useEffect(() => { return () => { @@ -337,32 +620,92 @@ export function useChatComposerState({ clearTimeout(abortTimeoutRef.current); abortTimeoutRef.current = null; } + + if (queueBootstrapTimerRef.current) { + clearTimeout(queueBootstrapTimerRef.current); + queueBootstrapTimerRef.current = null; + } + + for (const timerId of queueDispatchAckTimersRef.current.values()) { + clearTimeout(timerId); + } + queueDispatchAckTimersRef.current.clear(); + queueDispatchLocksRef.current.clear(); + pendingQueueDispatchesRef.current.clear(); + codexBusyRejectedDispatchesRef.current.clear(); }; }, []); + useEffect(() => { + setIsQueueBootstrapReady(false); + if (queueBootstrapTimerRef.current) { + clearTimeout(queueBootstrapTimerRef.current); + queueBootstrapTimerRef.current = null; + } + }, [selectedProject?.name]); + + useEffect(() => { + if (!selectedProject?.name) { + return; + } + + safeLocalStorage.setItem( + getCodexQueueStorageKey(selectedProject.name), + JSON.stringify(queuedTurnsBySession), + ); + }, [queuedTurnsBySession, selectedProject?.name]); + + useEffect(() => { + if (isQueueBootstrapReady || queueBootstrapTimerRef.current) { + return; + } + + const queuedSessionIds = Object.entries(queuedTurnsBySession) + .filter(([, queue]) => queue.some((turn) => turn.status === "queued")) + .map(([sessionId]) => sessionId); + + if (queuedSessionIds.length === 0) { + setIsQueueBootstrapReady(true); + return; + } + + queuedSessionIds.forEach((sessionId) => { + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + }); + + queueBootstrapTimerRef.current = setTimeout(() => { + setIsQueueBootstrapReady(true); + queueBootstrapTimerRef.current = null; + }, 1200); + }, [isQueueBootstrapReady, queuedTurnsBySession, sendMessage]); + useEffect(() => { setPendingStageTagKeys([]); }, [selectedProject?.name, selectedSession?.id]); useEffect(() => { - safeLocalStorage.setItem('codex-reasoning-effort', codexReasoningEffort); + safeLocalStorage.setItem("codex-reasoning-effort", codexReasoningEffort); }, [codexReasoningEffort]); useEffect(() => { - safeLocalStorage.setItem('gemini-thinking-mode', geminiThinkingMode); + safeLocalStorage.setItem("gemini-thinking-mode", geminiThinkingMode); }, [geminiThinkingMode]); useEffect(() => { const supportedEfforts = getSupportedCodexReasoningEfforts(codexModel); if (!supportedEfforts.includes(codexReasoningEffort)) { - setCodexReasoningEffort('default'); + setCodexReasoningEffort("default"); } }, [codexModel, codexReasoningEffort]); useEffect(() => { const supportedModes = getSupportedGeminiThinkingModes(geminiModel); if (!supportedModes.includes(geminiThinkingMode)) { - setGeminiThinkingMode('default'); + setGeminiThinkingMode("default"); } }, [geminiModel, geminiThinkingMode]); @@ -370,58 +713,62 @@ export function useChatComposerState({ (result: CommandExecutionResult) => { const { action, data } = result; switch (action) { - case 'clear': + case "clear": setChatMessages([]); setSessionMessages?.([]); break; - case 'help': + case "help": setChatMessages((previous) => [ ...previous, { - type: 'assistant', + type: "assistant", content: data.content, timestamp: Date.now(), }, ]); break; - case 'model': + case "model": setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`, + type: "assistant", + content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(", ")}\n\nCursor: ${data.available.cursor.join(", ")}`, timestamp: Date.now(), }, ]); break; - case 'cost': { + case "cost": { const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`; setChatMessages((previous) => [ ...previous, - { type: 'assistant', content: costMessage, timestamp: Date.now() }, + { type: "assistant", content: costMessage, timestamp: Date.now() }, ]); break; } - case 'status': { + case "status": { const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; setChatMessages((previous) => [ ...previous, - { type: 'assistant', content: statusMessage, timestamp: Date.now() }, + { + type: "assistant", + content: statusMessage, + timestamp: Date.now(), + }, ]); break; } - case 'memory': + case "memory": if (data.error) { setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `⚠️ ${data.message}`, + type: "assistant", + content: `閳跨媴绗?${data.message}`, timestamp: Date.now(), }, ]); @@ -429,8 +776,8 @@ export function useChatComposerState({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `📝 ${data.message}\n\nPath: \`${data.path}\``, + type: "assistant", + content: `棣冩憫 ${data.message}\n\nPath: \`${data.path}\``, timestamp: Date.now(), }, ]); @@ -440,17 +787,17 @@ export function useChatComposerState({ } break; - case 'config': + case "config": onShowSettings?.(); break; - case 'rewind': + case "rewind": if (data.error) { setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `⚠️ ${data.message}`, + type: "assistant", + content: `閳跨媴绗?${data.message}`, timestamp: Date.now(), }, ]); @@ -459,8 +806,8 @@ export function useChatComposerState({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `⏪ ${data.message}`, + type: "assistant", + content: `閳?${data.message}`, timestamp: Date.now(), }, ]); @@ -468,46 +815,49 @@ export function useChatComposerState({ break; default: - console.warn('Unknown built-in command action:', action); + console.warn("Unknown built-in command action:", action); } }, [onFileOpen, onShowSettings, setChatMessages, setSessionMessages], ); - const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => { - const { content, hasBashCommands } = result; + const handleCustomCommand = useCallback( + async (result: CommandExecutionResult) => { + const { content, hasBashCommands } = result; - if (hasBashCommands) { - const confirmed = window.confirm( - 'This command contains bash commands that will be executed. Do you want to proceed?', - ); - if (!confirmed) { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: '❌ Command execution cancelled', - timestamp: Date.now(), - }, - ]); - return; + if (hasBashCommands) { + const confirmed = window.confirm( + "This command contains bash commands that will be executed. Do you want to proceed?", + ); + if (!confirmed) { + setChatMessages((previous) => [ + ...previous, + { + type: "assistant", + content: "閴?Command execution cancelled", + timestamp: Date.now(), + }, + ]); + return; + } } - } - const commandContent = content || ''; - setInput(commandContent); - inputValueRef.current = commandContent; + const commandContent = content || ""; + setInput(commandContent); + inputValueRef.current = commandContent; - // Defer submit to next tick so the command text is reflected in UI before dispatching. - setTimeout(() => { - if (handleSubmitRef.current) { - handleSubmitRef.current(createFakeSubmitEvent()); - } - }, 0); - }, [setChatMessages]); + // Defer submit to next tick so the command text is reflected in UI before dispatching. + setTimeout(() => { + if (handleSubmitRef.current) { + handleSubmitRef.current(createFakeSubmitEvent()); + } + }, 0); + }, + [setChatMessages], + ); const submitProgrammaticInput = useCallback((content: string) => { - const nextContent = content || ''; + const nextContent = content || ""; setInput(nextContent); inputValueRef.current = nextContent; @@ -518,7 +868,9 @@ export function useChatComposerState({ } if (attempt >= PROGRAMMATIC_SUBMIT_MAX_RETRIES) { - console.warn('[Chat] Programmatic submit skipped because handleSubmit was not ready'); + console.warn( + "[Chat] Programmatic submit skipped because handleSubmit was not ready", + ); return; } @@ -540,9 +892,13 @@ export function useChatComposerState({ try { const effectiveInput = rawInput ?? input; - const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`)); + const commandMatch = effectiveInput.match( + new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`), + ); const args = - commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : []; + commandMatch && commandMatch[1] + ? commandMatch[1].trim().split(/\s+/) + : []; const context = { projectPath: selectedProject.fullPath || selectedProject.path, @@ -550,26 +906,18 @@ export function useChatComposerState({ sessionId: currentSessionId, provider, model: - provider === 'cursor' + provider === "cursor" ? cursorModel - : provider === 'codex' + : provider === "codex" ? codexModel - : provider === 'gemini' - ? geminiModel - : provider === 'openrouter' - ? openrouterModel - : provider === 'local' - ? localModel - : provider === 'nano' - ? nanoModel - : claudeModel, + : claudeModel, tokenUsage: tokenBudget, }; - const response = await authenticatedFetch('/api/commands/execute', { - method: 'POST', + const response = await authenticatedFetch("/api/commands/execute", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ commandName: command.name, @@ -583,7 +931,8 @@ export function useChatComposerState({ let errorMessage = `Failed to execute command (${response.status})`; try { const errorData = await response.json(); - errorMessage = errorData?.message || errorData?.error || errorMessage; + errorMessage = + errorData?.message || errorData?.error || errorMessage; } catch { // Ignore JSON parse failures and use fallback message. } @@ -591,104 +940,21 @@ export function useChatComposerState({ } const result = (await response.json()) as CommandExecutionResult; - if (result.type === 'builtin' && result.action === 'btw') { - const { data } = result; - setInput(''); - inputValueRef.current = ''; - if (data?.error) { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: `⚠️ ${data.error}`, - timestamp: Date.now(), - }, - ]); - return; - } - if (provider !== 'claude') { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: - '`/btw` is only available with the Claude Code provider. Switch to Claude in the chat controls, then try again.', - timestamp: Date.now(), - }, - ]); - return; - } - const question = typeof data?.question === 'string' ? data.question.trim() : ''; - if (!question) { - return; - } - btwAbortRef.current?.abort(); - const abortController = new AbortController(); - btwAbortRef.current = abortController; - setBtwOverlay({ - open: true, - question, - answer: '', - loading: true, - error: null, - }); - try { - const transcript = buildBtwTranscript(getChatMessagesForBtw?.() ?? []); - const btwResponse = await authenticatedFetch('/api/claude/btw', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - question, - transcript, - projectPath: selectedProject.fullPath || selectedProject.path, - model: claudeModel, - }), - signal: abortController.signal, - }); - const payload = (await btwResponse.json().catch(() => ({}))) as { - answer?: string; - error?: string; - message?: string; - }; - if (!btwResponse.ok) { - throw new Error(payload?.error || payload?.message || `Request failed (${btwResponse.status})`); - } - setBtwOverlay((previous) => ({ - ...previous, - loading: false, - answer: typeof payload.answer === 'string' ? payload.answer : '', - error: null, - })); - } catch (btwErr) { - if (abortController.signal.aborted) { - return; - } - const msg = btwErr instanceof Error ? btwErr.message : 'Unknown error'; - setBtwOverlay((previous) => ({ - ...previous, - loading: false, - error: msg, - answer: '', - })); - } - return; - } - if (result.type === 'builtin') { + if (result.type === "builtin") { handleBuiltInCommand(result); - setInput(''); - inputValueRef.current = ''; - } else if (result.type === 'custom') { + setInput(""); + inputValueRef.current = ""; + } else if (result.type === "custom") { await handleCustomCommand(result); } } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error executing command:', error); + const message = + error instanceof Error ? error.message : "Unknown error"; + console.error("Error executing command:", error); setChatMessages((previous) => [ ...previous, { - type: 'assistant', + type: "assistant", content: `Error executing command: ${message}`, timestamp: Date.now(), }, @@ -764,8 +1030,8 @@ export function useChatComposerState({ files.forEach((file) => { try { - if (!file || typeof file !== 'object') { - console.warn('Invalid file object:', file); + if (!file || typeof file !== "object") { + console.warn("Invalid file object:", file); return; } @@ -774,7 +1040,10 @@ export function useChatComposerState({ if (!file.size) { setFileErrors((previous) => { const next = new Map(previous); - next.set(attachmentKey, `${file.name || 'Unknown file'}: Empty files are not supported`); + next.set( + attachmentKey, + `${file.name || "Unknown file"}: Empty files are not supported`, + ); return next; }); return; @@ -783,7 +1052,10 @@ export function useChatComposerState({ if (file.size > MAX_ATTACHMENT_SIZE_BYTES) { setFileErrors((previous) => { const next = new Map(previous); - next.set(attachmentKey, `${file.name || 'Unknown file'}: File too large (max 50MB)`); + next.set( + attachmentKey, + `${file.name || "Unknown file"}: File too large (max 50MB)`, + ); return next; }); return; @@ -791,7 +1063,7 @@ export function useChatComposerState({ validFiles.push(file); } catch (error) { - console.error('Error validating file:', error, file); + console.error("Error validating file:", error, file); } }); @@ -808,7 +1080,9 @@ export function useChatComposerState({ const deduped = [...previous]; validFiles.forEach((file) => { const nextKey = getAttachmentKey(file); - if (!deduped.some((existing) => getAttachmentKey(existing) === nextKey)) { + if ( + !deduped.some((existing) => getAttachmentKey(existing) === nextKey) + ) { deduped.push(file); } }); @@ -860,7 +1134,7 @@ export function useChatComposerState({ const items = Array.from(event.clipboardData.items); items.forEach((item) => { - if (item.kind !== 'file') { + if (item.kind !== "file") { return; } const file = item.getAsFile(); @@ -896,17 +1170,20 @@ export function useChatComposerState({ const formData = new FormData(); files.forEach((file) => { - formData.append('images', file); + formData.append("images", file); }); - const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(selectedProject?.name || '')}/upload-images`, { - method: 'POST', - headers: {}, - body: formData, - }); + const response = await authenticatedFetch( + `/api/projects/${encodeURIComponent(selectedProject?.name || "")}/upload-images`, + { + method: "POST", + headers: {}, + body: formData, + }, + ); if (!response.ok) { - throw new Error('Failed to upload images'); + throw new Error("Failed to upload images"); } const result = await response.json(); @@ -923,53 +1200,689 @@ export function useChatComposerState({ const formData = new FormData(); const targetDir = `${CODEX_ATTACHMENT_DIR}/${Date.now()}`; - formData.append('targetDir', targetDir); + formData.append("targetDir", targetDir); files.forEach((file) => { - formData.append('files', file); + formData.append("files", file); }); - const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(selectedProject.name)}/upload-files`, { - method: 'POST', - headers: {}, - body: formData, - }); + const response = await authenticatedFetch( + `/api/projects/${encodeURIComponent(selectedProject.name)}/upload-files`, + { + method: "POST", + headers: {}, + body: formData, + }, + ); if (!response.ok) { - throw new Error('Failed to upload files'); + throw new Error("Failed to upload files"); } const result = await response.json(); - return Array.isArray(result.files) ? (result.files as UploadedProjectFile[]) : []; + return Array.isArray(result.files) + ? (result.files as UploadedProjectFile[]) + : []; }, [selectedProject], ); + const resolveSessionContext = useCallback(() => { + const routedSessionId = getRouteSessionId(); + const projectName = selectedProject?.name || null; + const resolvedProvider = normalizeProvider(provider); + + // If we're on the root path with no routed session and no selected session, + // treat this as an explicit new-session start and clear stale IDs. + const isExplicitNewSessionStart = + pathname === "/" && + !routedSessionId && + !selectedSession?.id; + if (isExplicitNewSessionStart) { + clearScopedProviderSessionId(projectName, "gemini"); + clearScopedProviderSessionId(projectName, "cursor"); + clearScopedPendingSessionId(projectName, "claude"); + clearScopedPendingSessionId(projectName, "cursor"); + clearScopedPendingSessionId(projectName, "codex"); + clearScopedPendingSessionId(projectName, "gemini"); + clearScopedPendingSessionId(projectName, "openrouter"); + clearScopedPendingSessionId(projectName, "local"); + lastSubmittedCodexSessionRef.current = null; + } + + const providerSessionId = + resolvedProvider === "gemini" || resolvedProvider === "cursor" + ? readScopedProviderSessionId(projectName, resolvedProvider) + : null; + const pendingSessionId = readScopedPendingSessionId(projectName, resolvedProvider); + const pendingViewSessionId = + pendingViewSessionRef.current?.sessionId || null; + const lastSubmittedSessionId = + provider === "codex" ? lastSubmittedCodexSessionRef.current : null; + const effectiveSessionId = + currentSessionId || + selectedSession?.id || + routedSessionId || + pendingViewSessionId || + pendingSessionId || + providerSessionId || + lastSubmittedSessionId; + const isNewSession = !effectiveSessionId; + const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; + const resolvedProjectPath = + selectedProject?.fullPath || selectedProject?.path || ""; + + return { + routedSessionId, + effectiveSessionId, + isNewSession, + sessionToActivate, + resolvedProvider, + resolvedProjectPath, + }; + }, [ + currentSessionId, + pathname, + pendingViewSessionRef, + provider, + selectedProject?.fullPath, + selectedProject?.path, + selectedSession?.id, + ]); + + const getToolsSettings = useCallback((resolvedProvider: SessionProvider) => { + try { + const settingsKey = getProviderSettingsKey(resolvedProvider); + const savedSettings = safeLocalStorage.getItem(settingsKey); + if (savedSettings) { + return JSON.parse(savedSettings); + } + } catch (error) { + console.error("Error loading tools settings:", error); + } + + return { + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + }; + }, []); + + const sendCodexTurn = useCallback( + ({ + text, + sessionId, + projectName, + projectPath, + sessionMode, + updateForegroundState = true, + appendLocalUserMessage = true, + }: { + text: string; + sessionId: string; + projectName?: string | null; + projectPath: string; + sessionMode: SessionMode; + updateForegroundState?: boolean; + appendLocalUserMessage?: boolean; + }) => { + if (!text.trim() || !sessionId || !projectPath) { + return; + } + + const turnStartTime = Date.now(); + + if (appendLocalUserMessage) { + setChatMessages((previous) => [ + ...previous, + { + type: "user", + content: text, + timestamp: new Date(), + }, + ]); + } + + if (updateForegroundState) { + setIsLoading(true); + setCanAbortSession(true); + setClaudeStatus({ + text: "Processing", + tokens: 0, + can_interrupt: true, + startTime: turnStartTime, + }); + setIsUserScrolledUp(false); + setTimeout(() => scrollToBottom(), 100); + } + + persistSessionTimerStart(sessionId, turnStartTime); + onSessionActive?.(sessionId, "codex", projectName || selectedProject?.name || null); + onSessionProcessing?.( + sessionId, + "codex", + projectName || selectedProject?.name || null, + ); + lastSubmittedCodexSessionRef.current = sessionId; + + sendMessage({ + type: "codex-command", + command: text, + sessionId, + options: { + cwd: projectPath, + projectPath, + sessionId, + resume: true, + model: codexModel, + permissionMode: + permissionMode === "plan" ? "default" : permissionMode, + modelReasoningEffort: + codexReasoningEffort === "default" + ? undefined + : codexReasoningEffort, + telemetryEnabled: isTelemetryEnabled(), + sessionMode, + }, + }); + }, + [ + codexModel, + codexReasoningEffort, + onSessionActive, + onSessionProcessing, + permissionMode, + scrollToBottom, + sendMessage, + setCanAbortSession, + setChatMessages, + setClaudeStatus, + setIsLoading, + setIsUserScrolledUp, + selectedProject?.name, + ], + ); + + const clearQueueDispatchAckTimer = useCallback((sessionId: string) => { + const timerId = queueDispatchAckTimersRef.current.get(sessionId); + if (timerId) { + clearTimeout(timerId); + queueDispatchAckTimersRef.current.delete(sessionId); + } + }, []); + + const releasePendingQueueDispatch = useCallback( + (sessionId: string) => { + clearQueueDispatchAckTimer(sessionId); + queueDispatchLocksRef.current.delete(sessionId); + pendingQueueDispatchesRef.current.delete(sessionId); + codexBusyRejectedDispatchesRef.current.delete(sessionId); + }, + [clearQueueDispatchAckTimer], + ); + + const dispatchNextQueuedCodexTurn = useCallback( + (sessionId: string, options?: { ignoreProcessingCheck?: boolean }) => { + if (!sessionId) { + return; + } + + if ( + queueDispatchLocksRef.current.has(sessionId) || + pendingQueueDispatchesRef.current.has(sessionId) + ) { + return; + } + + const queue = getSessionQueue(queuedTurnsBySessionRef.current, sessionId); + const nextTurn = getNextDispatchableTurn(queue); + if (!nextTurn) { + return; + } + + if ( + !options?.ignoreProcessingCheck && + hasProcessingSession( + sessionId, + "codex", + nextTurn.projectName || selectedProject?.name || currentProjectName, + ) + ) { + return; + } + + const queuedProjectPath = + nextTurn.projectPath || + selectedProject?.fullPath || + selectedProject?.path || + ""; + if (!queuedProjectPath) { + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: + "Unable to execute queued message: project path is unavailable.", + timestamp: new Date(), + }, + ]); + return; + } + + const routedSessionId = getRouteSessionId(); + const currentViewSessionId = + selectedSession?.id || + routedSessionId || + currentSessionId || + pendingViewSessionRef.current?.sessionId || + null; + const shouldUpdateForegroundState = + currentViewSessionId === sessionId || + (!currentViewSessionId && + lastSubmittedCodexSessionRef.current === sessionId); + + queueDispatchLocksRef.current.add(sessionId); + pendingQueueDispatchesRef.current.set(sessionId, { + turn: nextTurn, + shouldUpdateForegroundState, + }); + + clearQueueDispatchAckTimer(sessionId); + const ackTimer = setTimeout(() => { + const pendingDispatch = + pendingQueueDispatchesRef.current.get(sessionId); + if (!pendingDispatch || pendingDispatch.turn.id !== nextTurn.id) { + return; + } + + queueDispatchAckTimersRef.current.delete(sessionId); + queueDispatchLocksRef.current.delete(sessionId); + pendingQueueDispatchesRef.current.delete(sessionId); + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + }, CODEX_QUEUE_DISPATCH_ACK_TIMEOUT_MS); + queueDispatchAckTimersRef.current.set(sessionId, ackTimer); + + sendCodexTurn({ + text: nextTurn.text, + sessionId, + projectName: nextTurn.projectName || selectedProject?.name || currentProjectName, + projectPath: queuedProjectPath, + sessionMode: + nextTurn.sessionMode || selectedSession?.mode || newSessionMode, + updateForegroundState: shouldUpdateForegroundState, + appendLocalUserMessage: false, + }); + }, + [ + clearQueueDispatchAckTimer, + currentSessionId, + newSessionMode, + pendingViewSessionRef, + hasProcessingSession, + selectedProject?.fullPath, + selectedProject?.name, + selectedProject?.path, + selectedSession?.id, + selectedSession?.mode, + currentProjectName, + sendCodexTurn, + sendMessage, + setChatMessages, + ], + ); + + const handleCodexTurnStarted = useCallback( + (sessionId?: string | null) => { + if (!sessionId) { + return; + } + + codexBusyRejectedDispatchesRef.current.delete(sessionId); + const pendingDispatch = pendingQueueDispatchesRef.current.get(sessionId); + if (!pendingDispatch) { + return; + } + + releasePendingQueueDispatch(sessionId); + setQueuedTurnsBySession((previous) => + removeQueuedTurn(previous, sessionId, pendingDispatch.turn.id), + ); + + if (pendingDispatch.shouldUpdateForegroundState) { + setChatMessages((previous) => [ + ...previous, + { + type: "user", + content: pendingDispatch.turn.text, + timestamp: new Date(), + }, + ]); + } + }, + [releasePendingQueueDispatch, setChatMessages], + ); + + const handleCodexTurnSettled = useCallback( + ( + sessionId?: string | null, + outcome: "complete" | "error" | "aborted" = "complete", + ) => { + if (!sessionId) { + return; + } + + codexBusyRejectedDispatchesRef.current.delete(sessionId); + releasePendingQueueDispatch(sessionId); + + if (outcome === "aborted") { + setQueuedTurnsBySession((previous) => + setSessionQueueStatus(previous, sessionId, "paused"), + ); + return; + } + + const fallbackTemporarySessionId = lastSubmittedCodexSessionRef.current; + if (fallbackTemporarySessionId) { + const reconciled = reconcileSettledSessionQueue( + queuedTurnsBySessionRef.current, + sessionId, + fallbackTemporarySessionId, + ); + if (reconciled !== queuedTurnsBySessionRef.current) { + queuedTurnsBySessionRef.current = reconciled; + setQueuedTurnsBySession(reconciled); + } + } + + queueDispatchLocksRef.current.add(sessionId); + setTimeout(() => { + queueDispatchLocksRef.current.delete(sessionId); + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + dispatchNextQueuedCodexTurn(sessionId); + }, CODEX_QUEUE_DISPATCH_AFTER_SETTLE_MS); + }, + [dispatchNextQueuedCodexTurn, releasePendingQueueDispatch, sendMessage], + ); + + const handleCodexSessionIdResolved = useCallback( + (previousSessionId?: string | null, actualSessionId?: string | null) => { + if ( + !previousSessionId || + !actualSessionId || + previousSessionId === actualSessionId + ) { + return; + } + + if (lastSubmittedCodexSessionRef.current === previousSessionId) { + lastSubmittedCodexSessionRef.current = actualSessionId; + } + + if (codexBusyRejectedDispatchesRef.current.has(previousSessionId)) { + codexBusyRejectedDispatchesRef.current.delete(previousSessionId); + codexBusyRejectedDispatchesRef.current.add(actualSessionId); + } + + if (queueDispatchLocksRef.current.has(previousSessionId)) { + queueDispatchLocksRef.current.delete(previousSessionId); + queueDispatchLocksRef.current.add(actualSessionId); + } + + const pendingDispatch = + pendingQueueDispatchesRef.current.get(previousSessionId); + if ( + pendingDispatch && + !pendingQueueDispatchesRef.current.has(actualSessionId) + ) { + pendingQueueDispatchesRef.current.set(actualSessionId, { + ...pendingDispatch, + turn: { + ...pendingDispatch.turn, + sessionId: actualSessionId, + }, + }); + } + pendingQueueDispatchesRef.current.delete(previousSessionId); + + clearQueueDispatchAckTimer(previousSessionId); + if (pendingDispatch) { + const ackTimer = setTimeout(() => { + const currentPending = + pendingQueueDispatchesRef.current.get(actualSessionId); + if ( + !currentPending || + currentPending.turn.id !== pendingDispatch.turn.id + ) { + return; + } + + queueDispatchAckTimersRef.current.delete(actualSessionId); + queueDispatchLocksRef.current.delete(actualSessionId); + pendingQueueDispatchesRef.current.delete(actualSessionId); + sendMessage({ + type: "check-session-status", + sessionId: actualSessionId, + provider: "codex", + }); + }, CODEX_QUEUE_DISPATCH_ACK_TIMEOUT_MS); + queueDispatchAckTimersRef.current.set(actualSessionId, ackTimer); + } + + const reconciled = reconcileSessionQueueId( + queuedTurnsBySessionRef.current, + previousSessionId, + actualSessionId, + ); + if (reconciled === queuedTurnsBySessionRef.current) { + return; + } + + queuedTurnsBySessionRef.current = reconciled; + setQueuedTurnsBySession(reconciled); + }, + [clearQueueDispatchAckTimer, sendMessage], + ); + + const handleCodexSessionBusy = useCallback( + (sessionId?: string | null) => { + if (!sessionId) { + return; + } + + codexBusyRejectedDispatchesRef.current.add(sessionId); + releasePendingQueueDispatch(sessionId); + queueDispatchLocksRef.current.delete(sessionId); + + setTimeout(() => { + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + dispatchNextQueuedCodexTurn(sessionId); + }, CODEX_QUEUE_DISPATCH_AFTER_SETTLE_MS); + }, + [dispatchNextQueuedCodexTurn, releasePendingQueueDispatch, sendMessage], + ); + + const handleCodexSessionStatusUpdate = useCallback( + (sessionId?: string | null, isProcessing?: boolean) => { + if (!sessionId) { + return; + } + + if (isProcessing) { + if (codexBusyRejectedDispatchesRef.current.has(sessionId)) { + codexBusyRejectedDispatchesRef.current.delete(sessionId); + return; + } + if (pendingQueueDispatchesRef.current.has(sessionId)) { + handleCodexTurnStarted(sessionId); + } + return; + } + + releasePendingQueueDispatch(sessionId); + queueDispatchLocksRef.current.delete(sessionId); + dispatchNextQueuedCodexTurn(sessionId); + }, + [ + dispatchNextQueuedCodexTurn, + handleCodexTurnStarted, + releasePendingQueueDispatch, + ], + ); + + const removeQueuedCodexTurn = useCallback( + (sessionId: string, turnId: string) => { + if (!sessionId || !turnId) { + return; + } + + const pendingDispatch = pendingQueueDispatchesRef.current.get(sessionId); + if (pendingDispatch?.turn.id === turnId) { + releasePendingQueueDispatch(sessionId); + } + + setQueuedTurnsBySession((previous) => + removeQueuedTurn(previous, sessionId, turnId), + ); + }, + [releasePendingQueueDispatch], + ); + + const promoteQueuedCodexTurnToSteer = useCallback( + (sessionId: string, turnId: string) => { + if (!sessionId || !turnId) { + return; + } + + setQueuedTurnsBySession((previous) => + promoteQueuedTurnToSteer(previous, sessionId, turnId), + ); + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + dispatchNextQueuedCodexTurn(sessionId); + }, + [dispatchNextQueuedCodexTurn, sendMessage], + ); + + const resumeQueuedCodexTurns = useCallback( + (sessionId: string) => { + if (!sessionId) { + return; + } + + setQueuedTurnsBySession((previous) => + setSessionQueueStatus(previous, sessionId, "queued"), + ); + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + dispatchNextQueuedCodexTurn(sessionId); + }, + [dispatchNextQueuedCodexTurn, sendMessage], + ); + + useEffect(() => { + if (!isQueueBootstrapReady) { + return; + } + + for (const [sessionId, queue] of Object.entries(queuedTurnsBySession)) { + if (!queue.some((turn) => turn.status === "queued")) { + continue; + } + + if ( + queueDispatchLocksRef.current.has(sessionId) || + pendingQueueDispatchesRef.current.has(sessionId) + ) { + continue; + } + + if (hasProcessingSession(sessionId, "codex", selectedProject?.name || currentProjectName)) { + continue; + } + + dispatchNextQueuedCodexTurn(sessionId); + } + }, [ + dispatchNextQueuedCodexTurn, + hasProcessingSession, + currentProjectName, + isQueueBootstrapReady, + queuedTurnsBySession, + selectedProject?.name, + ]); + + const activeQueueSessionId = + selectedSession?.id || + getRouteSessionId() || + currentSessionId || + pendingViewSessionRef.current?.sessionId || + lastSubmittedCodexSessionRef.current || + null; + const activeQueuedTurns = activeQueueSessionId + ? getSessionQueue(queuedTurnsBySession, activeQueueSessionId) + : []; + const isActiveQueuePaused = activeQueuedTurns.some( + (turn) => turn.status === "paused", + ); + const handleSubmit = useCallback( async ( - event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, + event: + | FormEvent + | MouseEvent + | TouchEvent + | KeyboardEvent, ) => { event.preventDefault(); + const forceSteerForSubmit = forceSteerForSubmitRef.current; + forceSteerForSubmitRef.current = false; const currentInput = inputValueRef.current; - if (!selectedProject) { + if ( + (!currentInput.trim() && + attachedFiles.length === 0 && + !attachedPrompt) || + !selectedProject + ) { return; } - if (!currentInput.trim() && attachedFiles.length === 0 && !attachedPrompt) { + + if (isLoading && provider !== "codex") { return; } const trimmedInput = currentInput.trim(); - if (trimmedInput.startsWith('/')) { - const firstSpace = trimmedInput.indexOf(' '); - const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput; - const matchedCommand = slashCommands.find((command: SlashCommand) => command.name === commandName); + if (trimmedInput.startsWith("/")) { + const firstSpace = trimmedInput.indexOf(" "); + const commandName = + firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput; + const matchedCommand = slashCommands.find( + (command: SlashCommand) => command.name === commandName, + ); if (matchedCommand) { if (isLoading && commandName !== '/btw') { return; } await executeCommand(matchedCommand, trimmedInput); - setInput(''); - inputValueRef.current = ''; + setInput(""); + inputValueRef.current = ""; setAttachedPrompt(null); setAttachedFiles([]); setUploadingFiles(new Map()); @@ -977,7 +1890,7 @@ export function useChatComposerState({ resetCommandMenuState(); setIsTextareaExpanded(false); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; } return; } @@ -989,12 +1902,12 @@ export function useChatComposerState({ const normalizedInput = currentInput.trim() || - t('input.attachmentOnlyFallback', { - defaultValue: 'Please inspect the attached files and help me with them.', + t("input.attachmentOnlyFallback", { + defaultValue: + "Please inspect the attached files and help me with them.", }); let messageContent = normalizedInput; - // Prepend attached prompt text if present if (attachedPrompt) { if (currentInput.trim()) { messageContent = `${attachedPrompt.promptText}\n\n${normalizedInput}`; @@ -1003,22 +1916,18 @@ export function useChatComposerState({ } } - // Auto-bypass permissions for autoresearch workflows - const effectivePermissionMode = isAutoResearchScenario(attachedPrompt?.scenarioId) - ? 'bypassPermissions' - : permissionMode; - - const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode); + const selectedThinkingMode = thinkingModes.find( + (mode: { id: string; prefix?: string }) => mode.id === thinkingMode, + ); if (selectedThinkingMode && selectedThinkingMode.prefix) { messageContent = `${selectedThinkingMode.prefix}: ${messageContent}`; } - // Inject intake greeting context for the first message after auto-intake if (intakeGreeting) { setChatMessages((previous) => [ ...previous, { - type: 'assistant', + type: "assistant", content: intakeGreeting, timestamp: new Date(), }, @@ -1027,6 +1936,90 @@ export function useChatComposerState({ setIntakeGreeting(null); } + const { + effectiveSessionId, + isNewSession, + sessionToActivate, + resolvedProvider, + resolvedProjectPath, + } = resolveSessionContext(); + const isCurrentViewSession = + !selectedSession?.id || + selectedSession?.id === sessionToActivate || + currentSessionId === sessionToActivate; + const isCodexSessionBusy = + resolvedProvider === "codex" && + (hasProcessingSession( + sessionToActivate, + resolvedProvider, + selectedProject?.name || currentProjectName, + ) || + (isLoading && isCurrentViewSession)); + const useSteerForThisSubmit = + resolvedProvider === "codex" && (steerMode || forceSteerForSubmit); + + if (isCodexSessionBusy) { + if (attachedFiles.length > 0) { + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: + "Queued Codex turns currently support text-only input. Remove attachments and resend.", + timestamp: new Date(), + }, + ]); + return; + } + + const existingQueue = getSessionQueue( + queuedTurnsBySessionRef.current, + sessionToActivate, + ); + const queueStatus: QueuedTurnStatus = existingQueue.some( + (turn) => turn.status === "paused", + ) + ? "paused" + : "queued"; + const queuedTurn = buildQueuedTurn({ + id: + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `queued-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + sessionId: sessionToActivate, + text: messageContent, + kind: (useSteerForThisSubmit ? "steer" : "normal") as QueuedTurnKind, + status: queueStatus, + createdAt: Date.now(), + projectName: selectedProject.name, + projectPath: resolvedProjectPath, + sessionMode: selectedSession?.mode || newSessionMode, + }); + + setQueuedTurnsBySession((previous) => + enqueueSessionTurn(previous, queuedTurn), + ); + setInput(""); + inputValueRef.current = ""; + setPendingStageTagKeys([]); + resetCommandMenuState(); + setAttachedPrompt(null); + setAttachedFiles([]); + setUploadingFiles(new Map()); + setFileErrors(new Map()); + setIsTextareaExpanded(false); + setThinkingMode("none"); + setSteerMode(false); + if (draftStorageKey) { + safeLocalStorage.removeItem(draftStorageKey); + } + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + return; + } + let uploadedImages: ChatImage[] = []; let codexAttachmentPayload: | { @@ -1042,12 +2035,13 @@ export function useChatComposerState({ try { uploadedFiles = await uploadFilesToProject(attachedFiles); } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('File upload failed:', error); + const message = + error instanceof Error ? error.message : "Unknown error"; + console.error("File upload failed:", error); setChatMessages((previous) => [ ...previous, { - type: 'error', + type: "error", content: `Failed to upload files: ${message}`, timestamp: new Date(), }, @@ -1057,7 +2051,10 @@ export function useChatComposerState({ messageAttachments = attachedFiles.map((file, index) => { const uploadedFile = uploadedFiles[index]; - const uploadedPath = uploadedFile?.path && typeof uploadedFile.path === 'string' ? uploadedFile.path : undefined; + const uploadedPath = + uploadedFile?.path && typeof uploadedFile.path === "string" + ? uploadedFile.path + : undefined; return { name: file.name, @@ -1070,11 +2067,11 @@ export function useChatComposerState({ if (uploadedFiles.length > 0) { const fileNote = `\n\n[Files available at the following paths]\n${uploadedFiles .map((file, index) => `${index + 1}. ${file.path}`) - .join('\n')}`; + .join("\n")}`; messageContent = `${messageContent}${fileNote}`; } - if (provider === 'codex') { + if (resolvedProvider === "codex") { codexAttachmentPayload = uploadedFiles.reduce( ( accumulator: { @@ -1086,7 +2083,9 @@ export function useChatComposerState({ ) => { const sourceFile = attachedFiles[index]; const uploadedPath = - uploadedFile?.path && typeof uploadedFile.path === 'string' ? uploadedFile.path : null; + uploadedFile?.path && typeof uploadedFile.path === "string" + ? uploadedFile.path + : null; if (!sourceFile || !uploadedPath) { return accumulator; @@ -1107,21 +2106,24 @@ export function useChatComposerState({ ); } - const imageFiles = attachedFiles.filter((file) => isImageAttachment(file)); + const imageFiles = attachedFiles.filter((file) => + isImageAttachment(file), + ); if (imageFiles.length > 0) { try { uploadedImages = await uploadPreviewImages(imageFiles); } catch (error) { - console.error('Image preview upload failed:', error); + console.error("Image preview upload failed:", error); } } } const userMessage: ChatMessage = { - type: 'user', + type: "user", content: normalizedInput, images: uploadedImages.length > 0 ? uploadedImages : undefined, - attachments: messageAttachments.length > 0 ? messageAttachments : undefined, + attachments: + messageAttachments.length > 0 ? messageAttachments : undefined, timestamp: new Date(), ...(attachedPrompt ? { attachedPrompt } : {}), }; @@ -1135,7 +2137,7 @@ export function useChatComposerState({ setIsLoading(true); setCanAbortSession(true); setClaudeStatus({ - text: 'Processing', + text: "Processing", tokens: 0, can_interrupt: true, startTime: turnStartTime, @@ -1144,81 +2146,77 @@ export function useChatComposerState({ setIsUserScrolledUp(false); setTimeout(() => scrollToBottom(), 100); - // Reuse the session currently represented by the route or pending view state. - // This prevents interrupted chats from being treated as brand new sessions. - const routedSessionId = getRouteSessionId(); - - // If we're on the root path with no routed session AND no selected session, - // treat it as an explicit new session start and clear any stale provider-specific session IDs. - const isExplicitNewSessionStart = window.location.pathname === '/' && !routedSessionId && !selectedSession?.id; - if (isExplicitNewSessionStart && typeof window !== 'undefined') { - sessionStorage.removeItem('geminiSessionId'); - sessionStorage.removeItem('cursorSessionId'); - sessionStorage.removeItem('pendingSessionId'); - } - - const providerSessionId = - provider === 'gemini' - ? sessionStorage.getItem('geminiSessionId') - : provider === 'cursor' - ? sessionStorage.getItem('cursorSessionId') - : null; - const pendingViewSessionId = pendingViewSessionRef.current?.sessionId || null; - const effectiveSessionId = - currentSessionId || - selectedSession?.id || - routedSessionId || - pendingViewSessionId || - providerSessionId; - const isNewSession = !effectiveSessionId; - const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; + if ( + typeof window !== "undefined" && + isNewSession && + selectedProject.name + ) { + const optimisticSessionCreatedDetail: OptimisticSessionCreatedDetail = { + sessionId: sessionToActivate, + projectName: selectedProject.name, + provider: resolvedProvider, + mode: newSessionMode, + displayName: getOptimisticSessionDisplayName(normalizedInput), + summary: getOptimisticSessionDisplayName(normalizedInput), + createdAt: new Date().toISOString(), + }; + window.dispatchEvent( + new CustomEvent( + OPTIMISTIC_SESSION_CREATED_EVENT, + { + detail: optimisticSessionCreatedDetail, + }, + ), + ); + } if (!effectiveSessionId && !selectedSession?.id) { - if (typeof window !== 'undefined') { - // Reset stale pending IDs from previous interrupted runs before creating a new one. - sessionStorage.removeItem('pendingSessionId'); + clearScopedPendingSessionId(selectedProject.name, resolvedProvider); + if (resolvedProvider === "cursor" || resolvedProvider === "gemini") { + clearScopedProviderSessionId(selectedProject.name, resolvedProvider); } - pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; + pendingViewSessionRef.current = { + sessionId: sessionToActivate, + startedAt: Date.now(), + }; + } + persistScopedPendingSessionId( + selectedProject.name, + resolvedProvider, + sessionToActivate, + ); + if (resolvedProvider === "cursor" || resolvedProvider === "gemini") { + persistScopedProviderSessionId( + selectedProject.name, + resolvedProvider, + sessionToActivate, + ); } persistSessionTimerStart(sessionToActivate, turnStartTime); - onSessionActive?.(sessionToActivate); - - const getToolsSettings = () => { - try { - const settingsKey = getProviderSettingsKey(provider); - const savedSettings = safeLocalStorage.getItem(settingsKey); - if (savedSettings) { - return JSON.parse(savedSettings); - } - } catch (error) { - console.error('Error loading tools settings:', error); - } - - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false, - }; - }; + onSessionActive?.(sessionToActivate, resolvedProvider, selectedProject.name); + onSessionProcessing?.( + sessionToActivate, + resolvedProvider, + selectedProject.name, + ); + if (resolvedProvider === "codex") { + lastSubmittedCodexSessionRef.current = sessionToActivate; + } - const toolsSettings = getToolsSettings(); - const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; + const toolsSettings = getToolsSettings(resolvedProvider); const telemetryEnabled = isTelemetryEnabled(); - console.log('[DEBUG] useChatComposerState - provider:', provider); - console.log('[DEBUG] useChatComposerState - effectiveSessionId:', effectiveSessionId); - if (isNewSession) { - const sessionModeContext = newSessionMode === 'workspace_qa' - ? '[Context: session-mode=workspace_qa]\n[Context: Treat this as a lightweight workspace Q&A session. Focus on answering questions about files, code, and project structure. Do not start the research intake or pipeline workflow unless the user explicitly asks for it.]\n\n' - : '[Context: session-mode=research]\n[Context: This is a research workflow session. Follow the normal project research instructions and pipeline behavior.]\n\n'; + const sessionModeContext = + newSessionMode === "workspace_qa" + ? "[Context: session-mode=workspace_qa]\n[Context: Treat this as a lightweight workspace Q&A session. Focus on answering questions about files, code, and project structure. Do not start the research intake or pipeline workflow unless the user explicitly asks for it.]\n\n" + : "[Context: session-mode=research]\n[Context: This is a research workflow session. Follow the normal project research instructions and pipeline behavior.]\n\n"; messageContent = `${sessionModeContext}${messageContent}`; } - if (provider === 'cursor') { - console.log('[DEBUG] Sending cursor-command'); + if (resolvedProvider === "cursor") { sendMessage({ - type: 'cursor-command', + type: "cursor-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1232,13 +2230,12 @@ export function useChatComposerState({ telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'gemini') { - console.log('[DEBUG] Sending gemini-command'); + } else if (resolvedProvider === "gemini") { sendMessage({ - type: 'gemini-command', + type: "gemini-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1247,20 +2244,19 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: geminiModel, - permissionMode: effectivePermissionMode, + permissionMode, thinkingMode: geminiThinkingMode, images: uploadedImages.length > 0 ? uploadedImages : undefined, toolsSettings, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'codex') { - console.log('[DEBUG] Sending codex-command'); + } else if (resolvedProvider === "codex") { sendMessage({ - type: 'codex-command', + type: "codex-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1269,20 +2265,23 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: codexModel, - permissionMode: effectivePermissionMode === 'plan' ? 'default' : effectivePermissionMode, - modelReasoningEffort: codexReasoningEffort === 'default' ? undefined : codexReasoningEffort, + permissionMode: + permissionMode === "plan" ? "default" : permissionMode, + modelReasoningEffort: + codexReasoningEffort === "default" + ? undefined + : codexReasoningEffort, attachments: codexAttachmentPayload, images: uploadedImages, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'openrouter') { - console.log('[DEBUG] Sending openrouter-command'); + } else if (resolvedProvider === "openrouter") { sendMessage({ - type: 'openrouter-command', + type: "openrouter-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1291,18 +2290,17 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: openrouterModel, - permissionMode: effectivePermissionMode, + permissionMode, toolsSettings, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'local') { - console.log('[DEBUG] Sending local-command'); + } else if (resolvedProvider === "local") { sendMessage({ - type: 'local-command', + type: "local-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1311,20 +2309,21 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: localModel, - serverUrl: localStorage.getItem('local-gpu-server-url') || 'http://localhost:11434', - gpuId: localStorage.getItem('local-gpu-selected') || undefined, - permissionMode: effectivePermissionMode, + serverUrl: + localStorage.getItem("local-gpu-server-url") || + "http://localhost:11434", + gpuId: localStorage.getItem("local-gpu-selected") || undefined, + permissionMode, toolsSettings, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'nano') { - console.log('[DEBUG] Sending nano-command'); + } else if (resolvedProvider === "nano") { sendMessage({ - type: 'nano-command', + type: "nano-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1337,13 +2336,12 @@ export function useChatComposerState({ telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); } else { - console.log('[DEBUG] Sending claude-command'); sendMessage({ - type: 'claude-command', + type: "claude-command", command: messageContent, options: { projectPath: resolvedProjectPath, @@ -1351,33 +2349,36 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), toolsSettings, - permissionMode: effectivePermissionMode, + permissionMode, model: claudeModel, images: uploadedImages.length > 0 ? uploadedImages : undefined, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); } - setInput(''); - inputValueRef.current = ''; + setInput(""); + inputValueRef.current = ""; setPendingStageTagKeys([]); resetCommandMenuState(); setAttachedFiles([]); setUploadingFiles(new Map()); setFileErrors(new Map()); setIsTextareaExpanded(false); - setThinkingMode('none'); + setThinkingMode("none"); setAttachedPrompt(null); + setSteerMode(false); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; } - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); + if (draftStorageKey) { + safeLocalStorage.removeItem(draftStorageKey); + } }, [ attachedFiles, @@ -1390,34 +2391,39 @@ export function useChatComposerState({ executeCommand, geminiThinkingMode, geminiModel, - openrouterModel, - localModel, - nanoModel, + getToolsSettings, + intakeGreeting, isLoading, + localModel, + newSessionMode, onSessionActive, + onSessionProcessing, + openrouterModel, + pendingStageTagKeys, pendingViewSessionRef, permissionMode, + processingSessions, provider, resetCommandMenuState, + resolveSessionContext, scrollToBottom, selectedProject, selectedSession?.id, + selectedSession?.mode, sendMessage, setCanAbortSession, setChatMessages, setClaudeStatus, setIsLoading, setIsUserScrolledUp, - pendingStageTagKeys, slashCommands, - thinkingMode, + steerMode, t, - intakeGreeting, + thinkingMode, uploadFilesToProject, uploadPreviewImages, ], ); - useEffect(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]); @@ -1427,16 +2433,16 @@ export function useChatComposerState({ }, [input]); useEffect(() => { - if (!selectedProject) { + if (!selectedProject || !draftStorageKey) { return; } - const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; + const savedInput = safeLocalStorage.getItem(draftStorageKey) || ""; setInput((previous) => { const next = previous === savedInput ? previous : savedInput; inputValueRef.current = next; return next; }); - }, [selectedProject?.name]); + }, [draftStorageKey, selectedProject]); useEffect(() => { if (!selectedProject) { @@ -1453,21 +2459,20 @@ export function useChatComposerState({ } textareaRef.current.focus(); - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; const cursor = draft.length; textareaRef.current.setSelectionRange(cursor, cursor); - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); - setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2); + const lineHeight = parseInt( + window.getComputedStyle(textareaRef.current).lineHeight, + ); + setIsTextareaExpanded( + textareaRef.current.scrollHeight > lineHeight * 2, + ); }, 0); }; const applyQueuedDraft = () => { - const skillDraft = consumeSkillCommandDraft(); - if (skillDraft) { - applyDraft(skillDraft); - return; - } const wqDraft = consumeWorkspaceQaDraft(selectedProject.name); if (wqDraft) { applyDraft(wqDraft); @@ -1480,14 +2485,18 @@ export function useChatComposerState({ if (refDraft.pdfCached && refDraft.referenceId) { (async () => { try { - const res = await authenticatedFetch(`/api/references/${refDraft.referenceId}/pdf`); + const res = await authenticatedFetch( + `/api/references/${refDraft.referenceId}/pdf`, + ); if (res.ok) { const blob = await res.blob(); - const file = new File([blob], `${refDraft.referenceId}.pdf`, { type: 'application/pdf' }); + const file = new File([blob], `${refDraft.referenceId}.pdf`, { + type: "application/pdf", + }); setAttachedFiles((prev: File[]) => [...prev, file].slice(0, 5)); } } catch { - // PDF fetch failed — user still has text context + // PDF fetch failed 閳?user still has text context } })(); } @@ -1506,33 +2515,33 @@ export function useChatComposerState({ window.addEventListener(WORKSPACE_QA_DRAFT_EVENT, handleQueuedDraft); window.addEventListener(REFERENCE_CHAT_DRAFT_EVENT, handleQueuedDraft); - window.addEventListener(SKILL_COMMAND_DRAFT_EVENT, handleQueuedDraft); return () => { window.removeEventListener(WORKSPACE_QA_DRAFT_EVENT, handleQueuedDraft); window.removeEventListener(REFERENCE_CHAT_DRAFT_EVENT, handleQueuedDraft); - window.removeEventListener(SKILL_COMMAND_DRAFT_EVENT, handleQueuedDraft); }; }, [selectedProject?.name, setInput]); useEffect(() => { - if (!selectedProject) { + if (!selectedProject || !draftStorageKey) { return; } - if (input !== '') { - safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input); + if (input !== "") { + safeLocalStorage.setItem(draftStorageKey, input); } else { - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); + safeLocalStorage.removeItem(draftStorageKey); } - }, [input, selectedProject]); + }, [draftStorageKey, input, selectedProject]); useEffect(() => { if (!textareaRef.current) { return; } // Re-run when input changes so restored drafts get the same autosize behavior as typed text. - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); + const lineHeight = parseInt( + window.getComputedStyle(textareaRef.current).lineHeight, + ); const expanded = textareaRef.current.scrollHeight > lineHeight * 2; setIsTextareaExpanded(expanded); }, [input]); @@ -1541,7 +2550,7 @@ export function useChatComposerState({ if (!textareaRef.current || input.trim()) { return; } - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; setIsTextareaExpanded(false); }, [input]); @@ -1556,7 +2565,7 @@ export function useChatComposerState({ if (!newValue.trim()) { setPendingStageTagKeys([]); - event.target.style.height = 'auto'; + event.target.style.height = "auto"; setIsTextareaExpanded(false); resetCommandMenuState(); return; @@ -1567,6 +2576,34 @@ export function useChatComposerState({ [handleCommandInputChange, resetCommandMenuState, setCursorPosition], ); + const isCodexQueueShortcutActive = useCallback(() => { + if (provider !== "codex") { + return false; + } + + const { sessionToActivate } = resolveSessionContext(); + const isCurrentViewSession = + !selectedSession?.id || + selectedSession.id === sessionToActivate || + currentSessionId === sessionToActivate; + + return hasProcessingSession( + sessionToActivate, + "codex", + selectedProject?.name || currentProjectName, + ) || + (isLoading && isCurrentViewSession); + }, [ + currentSessionId, + currentProjectName, + hasProcessingSession, + isLoading, + provider, + resolveSessionContext, + selectedProject?.name, + selectedSession?.id, + ]); + const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (handleCommandMenuKeyDown(event)) { @@ -1577,21 +2614,29 @@ export function useChatComposerState({ return; } - if (event.key === 'Tab' && !showFileDropdown && !showCommandMenu) { + if (event.key === "Tab" && !showFileDropdown && !showCommandMenu) { event.preventDefault(); cyclePermissionMode(); return; } - if (event.key === 'Enter') { + if (event.key === "Enter") { if (event.nativeEvent.isComposing) { return; } if ((event.ctrlKey || event.metaKey) && !event.shiftKey) { event.preventDefault(); + if (provider === "codex") { + forceSteerForSubmitRef.current = true; + } handleSubmit(event); - } else if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter) { + } else if ( + !event.shiftKey && + !event.ctrlKey && + !event.metaKey && + (!sendByCtrlEnter || isCodexQueueShortcutActive()) + ) { event.preventDefault(); handleSubmit(event); } @@ -1602,6 +2647,8 @@ export function useChatComposerState({ handleCommandMenuKeyDown, handleFileMentionsKeyDown, handleSubmit, + isCodexQueueShortcutActive, + provider, sendByCtrlEnter, showCommandMenu, showFileDropdown, @@ -1618,7 +2665,7 @@ export function useChatComposerState({ const handleTextareaInput = useCallback( (event: FormEvent) => { const target = event.currentTarget; - target.style.height = 'auto'; + target.style.height = "auto"; target.style.height = `${target.scrollHeight}px`; setCursorPosition(target.selectionStart); syncInputOverlayScroll(target); @@ -1630,15 +2677,15 @@ export function useChatComposerState({ ); const handleClearInput = useCallback(() => { - setInput(''); - inputValueRef.current = ''; + setInput(""); + inputValueRef.current = ""; setPendingStageTagKeys([]); setAttachedFiles([]); setUploadingFiles(new Map()); setFileErrors(new Map()); resetCommandMenuState(); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.focus(); } setIsTextareaExpanded(false); @@ -1661,38 +2708,62 @@ export function useChatComposerState({ setCanAbortSession(false); - const pendingSessionId = - typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; - const cursorSessionId = - typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null; + const selectedSessionProvider = normalizeProvider( + selectedSession?.__provider || provider, + ); + const pendingSessionId = readScopedPendingSessionId( + currentProjectName, + selectedSessionProvider, + ); + const providerSessionId = + selectedSessionProvider === "cursor" || + selectedSessionProvider === "gemini" + ? readScopedProviderSessionId(currentProjectName, selectedSessionProvider) + : null; const candidateSessionIds = [ currentSessionId, pendingViewSessionRef.current?.sessionId || null, pendingSessionId, - provider === 'cursor' ? cursorSessionId : null, + providerSessionId, selectedSession?.id || null, ]; const targetSessionId = - candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null; + candidateSessionIds.find( + (sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId), + ) || null; if (!targetSessionId) { + const recoverySessionIds = Array.from( + new Set( + candidateSessionIds.filter( + (sessionId): sessionId is string => Boolean(sessionId), + ), + ), + ); + + for (const sessionId of recoverySessionIds) { + clearSessionTimerStart(sessionId); + if (provider === "codex") { + releasePendingQueueDispatch(sessionId); + } + sendMessage({ + type: "check-session-status", + sessionId, + provider, + }); + } + setIsLoading(false); + setCanAbortSession(false); setClaudeStatus(null); - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: 'Could not stop session: no active session found.', - timestamp: new Date(), - }, - ]); + setPendingPermissionRequests([]); return; } sendMessage({ - type: 'abort-session', + type: "abort-session", sessionId: targetSessionId, provider, }); @@ -1707,7 +2778,22 @@ export function useChatComposerState({ setClaudeStatus(null); if (targetSessionId) clearSessionTimerStart(targetSessionId); }, 5000); - }, [canAbortSession, currentSessionId, isLoading, pendingViewSessionRef, provider, selectedSession?.id, sendMessage, setCanAbortSession, setChatMessages, setClaudeStatus, setIsLoading, setPendingPermissionRequests]); + }, [ + canAbortSession, + currentProjectName, + currentSessionId, + isLoading, + pendingViewSessionRef, + provider, + selectedSession?.id, + selectedSession?.__provider, + sendMessage, + releasePendingQueueDispatch, + setCanAbortSession, + setClaudeStatus, + setIsLoading, + setPendingPermissionRequests, + ]); const handleTranscript = useCallback((text: string) => { if (!text.trim()) { @@ -1723,10 +2809,14 @@ export function useChatComposerState({ return; } - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); - setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2); + const lineHeight = parseInt( + window.getComputedStyle(textareaRef.current).lineHeight, + ); + setIsTextareaExpanded( + textareaRef.current.scrollHeight > lineHeight * 2, + ); }, 0); return newInput; @@ -1735,7 +2825,7 @@ export function useChatComposerState({ const handleGrantToolPermission = useCallback( (suggestion: { entry: string; toolName: string }) => { - if (!suggestion || (provider !== 'claude' && provider !== 'gemini')) { + if (!suggestion || (provider !== "claude" && provider !== "gemini")) { return { success: false }; } return grantToolPermission(suggestion.entry, provider); @@ -1746,7 +2836,12 @@ export function useChatComposerState({ const handlePermissionDecision = useCallback( ( requestIds: string | string[], - decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown }, + decision: { + allow?: boolean; + message?: string; + rememberEntry?: string | null; + updatedInput?: unknown; + }, ) => { const ids = Array.isArray(requestIds) ? requestIds : [requestIds]; const validIds = ids.filter(Boolean); @@ -1756,7 +2851,7 @@ export function useChatComposerState({ validIds.forEach((requestId) => { sendMessage({ - type: 'claude-permission-response', + type: "claude-permission-response", requestId, allow: Boolean(decision?.allow), updatedInput: decision?.updatedInput, @@ -1766,12 +2861,16 @@ export function useChatComposerState({ }); // Update the local chatMessage toolInput so answered questions render with selections - if (decision?.updatedInput && typeof decision.updatedInput === 'object' && 'answers' in (decision.updatedInput as Record)) { + if ( + decision?.updatedInput && + typeof decision.updatedInput === "object" && + "answers" in (decision.updatedInput as Record) + ) { const updated = decision.updatedInput as Record; setChatMessages((previous) => { const msgs = [...previous]; for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].toolName === 'AskUserQuestion' && msgs[i].isToolUse) { + if (msgs[i].toolName === "AskUserQuestion" && msgs[i].isToolUse) { msgs[i] = { ...msgs[i], toolInput: updated }; break; } @@ -1781,14 +2880,21 @@ export function useChatComposerState({ } setPendingPermissionRequests((previous) => { - const next = previous.filter((request) => !validIds.includes(request.requestId)); + const next = previous.filter( + (request) => !validIds.includes(request.requestId), + ); if (next.length === 0) { setClaudeStatus(null); } return next; }); }, - [sendMessage, setChatMessages, setClaudeStatus, setPendingPermissionRequests], + [ + sendMessage, + setChatMessages, + setClaudeStatus, + setPendingPermissionRequests, + ], ); const [isInputFocused, setIsInputFocused] = useState(false); @@ -1809,6 +2915,8 @@ export function useChatComposerState({ textareaRef, inputHighlightRef, isTextareaExpanded, + steerMode, + setSteerMode, thinkingMode, setThinkingMode, codexReasoningEffort, @@ -1853,9 +2961,20 @@ export function useChatComposerState({ isInputFocused, intakeGreeting, setIntakeGreeting, - setPendingStageTagKeys, - submitProgrammaticInput, btwOverlay, closeBtwOverlay, + setPendingStageTagKeys, + submitProgrammaticInput, + activeQueueSessionId, + activeQueuedTurns, + isActiveQueuePaused, + removeQueuedCodexTurn, + promoteQueuedCodexTurnToSteer, + resumeQueuedCodexTurns, + handleCodexTurnStarted, + handleCodexTurnSettled, + handleCodexSessionIdResolved, + handleCodexSessionBusy, + handleCodexSessionStatusUpdate, }; } diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 72bf8809..6ac832ba 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,22 +1,39 @@ -import { useEffect, useRef } from 'react'; -import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { useEffect, useRef } from "react"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { buildAssistantMessages, decodeHtmlEntities, formatUsageLimitText, unescapeWithMathProtection, -} from '../utils/chatFormatting'; -import { parseAskUserAnswers, mergeAnswersIntoToolInput } from '../utils/messageTransforms'; +} from "../utils/chatFormatting"; import { + parseAskUserAnswers, + mergeAnswersIntoToolInput, +} from "../utils/messageTransforms"; +import { + buildChatMessagesStorageKey, + clearScopedPendingSessionId, clearSessionTimerStart, moveSessionTimerStart, persistSessionTimerStart, + persistScopedPendingSessionId, + persistScopedProviderSessionId, + readScopedPendingSessionId, safeLocalStorage, -} from '../utils/chatStorage'; -import { RESUMING_STATUS_TEXT } from '../types/types'; -import i18n from '../../../i18n/config'; -import type { ChatMessage, PendingPermissionRequest } from '../types/types'; -import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +} from "../utils/chatStorage"; +import { + emitSessionFilterDebugLog, + syncSessionFilterDebugSetting, +} from "../utils/sessionFilterDebug"; +import { RESUMING_STATUS_TEXT } from "../types/types"; +import i18n from "../../../i18n/config"; +import type { ChatMessage, PendingPermissionRequest } from "../types/types"; +import type { + Project, + ProjectSession, + SessionProvider, +} from "../../../types/app"; +import { isProviderAllowed, normalizeProvider } from "../../../utils/providerPolicy"; type PendingViewSession = { sessionId: string | null; @@ -39,6 +56,8 @@ type LatestChatMessage = { [key: string]: any; }; +const warnedUnknownProviders = new Set(); + interface UseChatRealtimeHandlersArgs { latestMessage: LatestChatMessage | null; provider: SessionProvider; @@ -49,24 +68,64 @@ interface UseChatRealtimeHandlersArgs { setChatMessages: Dispatch>; setIsLoading: (loading: boolean) => void; setCanAbortSession: (canAbort: boolean) => void; - setClaudeStatus: Dispatch>; + setClaudeStatus: Dispatch< + SetStateAction<{ + text: string; + tokens: number; + can_interrupt: boolean; + startTime?: number; + } | null> + >; setStatusTextOverride: Dispatch>; setTokenBudget: (budget: Record | null) => void; setIsSystemSessionChange: (isSystemSessionChange: boolean) => void; - setPendingPermissionRequests: Dispatch>; + setPendingPermissionRequests: Dispatch< + SetStateAction + >; pendingViewSessionRef: MutableRefObject; streamBufferRef: MutableRefObject; streamTimerRef: MutableRefObject; - onSessionInactive?: (sessionId?: string | null) => void; - onSessionProcessing?: (sessionId?: string | null) => void; - onSessionNotProcessing?: (sessionId?: string | null) => void; - onSessionStatusResolved?: (sessionId?: string | null, isProcessing?: boolean) => void; - onReplaceTemporarySession?: (sessionId?: string | null) => void; + onSessionInactive?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionProcessing?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionNotProcessing?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionStatusResolved?: ( + sessionId?: string | null, + isProcessing?: boolean, + ) => void; + onCodexTurnStarted?: (sessionId?: string | null) => void; + onCodexTurnSettled?: ( + sessionId?: string | null, + outcome?: "complete" | "error" | "aborted", + ) => void; + onCodexSessionBusy?: (sessionId?: string | null) => void; + onCodexSessionIdResolved?: ( + previousSessionId?: string | null, + actualSessionId?: string | null, + ) => void; + onReplaceTemporarySession?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + previousSessionId?: string | null, + ) => void; onNavigateToSession?: ( sessionId: string, sessionProvider?: SessionProvider, targetProjectName?: string, ) => void; + sendMessage?: (message: Record) => void; } const appendStreamingChunk = ( @@ -82,15 +141,25 @@ const appendStreamingChunk = ( const updated = [...previous]; const lastIndex = updated.length - 1; const last = updated[lastIndex]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { + if ( + last && + last.type === "assistant" && + !last.isToolUse && + last.isStreaming + ) { const nextContent = newline ? last.content ? `${last.content}\n${chunk}` : chunk - : `${last.content || ''}${chunk}`; + : `${last.content || ""}${chunk}`; updated[lastIndex] = { ...last, content: nextContent }; } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); + updated.push({ + type: "assistant", + content: chunk, + timestamp: new Date(), + isStreaming: true, + }); } return updated; }); @@ -98,14 +167,21 @@ const appendStreamingChunk = ( // NOTE: unescapeWithMathProtection, formatUsageLimitText, and splitLegacyGeminiThoughtContent // are safe no-ops for non-Gemini text, so no provider guard is needed here. -const finalizeStreamingMessage = (setChatMessages: Dispatch>) => { +const finalizeStreamingMessage = ( + setChatMessages: Dispatch>, +) => { setChatMessages((previous) => { const updated = [...previous]; const lastIndex = updated.length - 1; const last = updated[lastIndex]; - if (last && last.type === 'assistant' && last.isStreaming) { - const normalizedContent = unescapeWithMathProtection(formatUsageLimitText(String(last.content || ''))); - const messages = buildAssistantMessages(normalizedContent, last.timestamp || new Date()); + if (last && last.type === "assistant" && last.isStreaming) { + const normalizedContent = unescapeWithMathProtection( + formatUsageLimitText(String(last.content || "")), + ); + const messages = buildAssistantMessages( + normalizedContent, + last.timestamp || new Date(), + ); updated.splice( lastIndex, 1, @@ -122,12 +198,15 @@ const finalizeStreamingMessage = (setChatMessages: Dispatch { - const normalized = String(value || '').toLowerCase(); - if (!normalized.includes('taskmaster')) { + const normalized = String(value || "").toLowerCase(); + if (!normalized.includes("taskmaster")) { return false; } - return normalized.includes('not installed') || normalized.includes('not configured'); + return ( + normalized.includes("not installed") || + normalized.includes("not configured") + ); }; export function useChatRealtimeHandlers({ @@ -152,13 +231,25 @@ export function useChatRealtimeHandlers({ onSessionProcessing, onSessionNotProcessing, onSessionStatusResolved, + onCodexTurnStarted, + onCodexTurnSettled, + onCodexSessionBusy, + onCodexSessionIdResolved, onReplaceTemporarySession, onNavigateToSession, + sendMessage, }: UseChatRealtimeHandlersArgs) { const lastProcessedMessageRef = useRef(null); + useEffect(() => { + syncSessionFilterDebugSetting(sendMessage); + }, [sendMessage]); + // Helper: Handle structured assistant content - const handleStructuredAssistantMessage = (structuredData: any, rawData: any) => { + const handleStructuredAssistantMessage = ( + structuredData: any, + rawData: any, + ) => { // New assistant message = previous tool execution done; clear override. // If this message contains a new Bash tool_use, it will be re-set below (React batches both updates). setStatusTextOverride(null); @@ -168,11 +259,11 @@ export function useChatRealtimeHandlers({ const childToolUpdates: { parentId: string; child: any }[] = []; structuredData.content.forEach((part: any) => { - if (part.type === 'thinking' || part.type === 'reasoning') { - const thinkingText = part.thinking || part.reasoning || part.text || ''; + if (part.type === "thinking" || part.type === "reasoning") { + const thinkingText = part.thinking || part.reasoning || part.text || ""; if (thinkingText.trim()) { newMessages.push({ - type: 'assistant', + type: "assistant", content: unescapeWithMathProtection(thinkingText), timestamp: new Date(), isThinking: true, @@ -182,12 +273,12 @@ export function useChatRealtimeHandlers({ return; } - if (part.type === 'tool_use') { - if (['Bash', 'run_shell_command'].includes(part.name)) { + if (part.type === "tool_use") { + if (["Bash", "run_shell_command"].includes(part.name)) { // Set running code status when command starts - setStatusTextOverride(i18n.t('chat:status.runningCode')); + setStatusTextOverride(i18n.t("chat:status.runningCode")); } - const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ''; + const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ""; if (parentToolUseId) { childToolUpdates.push({ @@ -203,10 +294,10 @@ export function useChatRealtimeHandlers({ return; } - const isSubagentContainer = part.name === 'Task'; + const isSubagentContainer = part.name === "Task"; newMessages.push({ - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, toolName: part.name, @@ -221,7 +312,7 @@ export function useChatRealtimeHandlers({ return; } - if (part.type === 'text' && part.text?.trim()) { + if (part.type === "text" && part.text?.trim()) { let content = decodeHtmlEntities(part.text); content = formatUsageLimitText(content); newMessages.push(...buildAssistantMessages(content, new Date())); @@ -234,7 +325,9 @@ export function useChatRealtimeHandlers({ if (childToolUpdates.length > 0) { updated = updated.map((message) => { if (!message.isSubagentContainer) return message; - const updates = childToolUpdates.filter((u) => u.parentId === message.toolId); + const updates = childToolUpdates.filter( + (u) => u.parentId === message.toolId, + ); if (updates.length === 0) return message; const existingChildren = message.subagentState?.childTools || []; const newChildren = updates.map((u) => u.child); @@ -242,7 +335,8 @@ export function useChatRealtimeHandlers({ ...message, subagentState: { childTools: [...existingChildren, ...newChildren], - currentToolIndex: existingChildren.length + newChildren.length - 1, + currentToolIndex: + existingChildren.length + newChildren.length - 1, isComplete: false, }, }; @@ -270,23 +364,28 @@ export function useChatRealtimeHandlers({ // Helper: Handle user tool results const handleUserToolResults = (structuredData: any, rawData: any) => { const parentToolUseId = rawData?.parentToolUseId; - const toolResults = structuredData.content.filter((part: any) => part.type === 'tool_result'); - const textParts = structuredData.content.filter((part: any) => part.type === 'text'); + const toolResults = structuredData.content.filter( + (part: any) => part.type === "tool_result", + ); + const textParts = structuredData.content.filter( + (part: any) => part.type === "text", + ); if (textParts.length > 0) { - const textContent = textParts.map((p: any) => p.text || '').join('\n'); + const textContent = textParts.map((p: any) => p.text || "").join("\n"); const isSkillText = - textContent.includes('Base directory for this skill:') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - (toolResults.length > 0 && !textContent.startsWith('')); + textContent.includes("Base directory for this skill:") || + textContent.startsWith("") || + textContent.startsWith("") || + textContent.startsWith("") || + textContent.startsWith("") || + (toolResults.length > 0 && + !textContent.startsWith("")); if (isSkillText && textContent.trim()) { setChatMessages((previous) => [ ...previous, { - type: 'user', + type: "user", content: textContent, timestamp: new Date(), isSkillContent: true, @@ -302,20 +401,26 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => previous.map((message) => { for (const part of toolResults) { - if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) { - const updatedChildren = message.subagentState!.childTools.map((child: any) => { - if (child.toolId === part.tool_use_id) { - return { - ...child, - toolResult: { - content: part.content, - isError: part.is_error, - timestamp: new Date(), - }, - }; - } - return child; - }); + if ( + parentToolUseId && + message.toolId === parentToolUseId && + message.isSubagentContainer + ) { + const updatedChildren = message.subagentState!.childTools.map( + (child: any) => { + if (child.toolId === part.tool_use_id) { + return { + ...child, + toolResult: { + content: part.content, + isError: part.is_error, + timestamp: new Date(), + }, + }; + } + return child; + }, + ); if (updatedChildren !== message.subagentState!.childTools) { return { ...message, @@ -336,8 +441,11 @@ export function useChatRealtimeHandlers({ timestamp: new Date(), }, }; - if (message.toolName === 'AskUserQuestion' && part.content) { - const resultStr = typeof part.content === 'string' ? part.content : JSON.stringify(part.content); + if (message.toolName === "AskUserQuestion" && part.content) { + const resultStr = + typeof part.content === "string" + ? part.content + : JSON.stringify(part.content); const parsedAnswers = parseAskUserAnswers(resultStr); if (parsedAnswers) { const inputStr = typeof message.toolInput === 'string' @@ -367,91 +475,391 @@ export function useChatRealtimeHandlers({ } if (lastProcessedMessageRef.current === latestMessage) { + emitSessionFilterDebugLog( + { + reason: "dropped:duplicate-message-reference", + messageType: String(latestMessage.type || ""), + routedSessionId: latestMessage.actualSessionId || latestMessage.sessionId || null, + actualSessionId: latestMessage.actualSessionId || null, + }, + sendMessage, + ); return; } lastProcessedMessageRef.current = latestMessage; const messageData = latestMessage.data?.message || latestMessage.data; const structuredMessageData = - messageData && typeof messageData === 'object' ? (messageData as Record) : null; + messageData && typeof messageData === "object" + ? (messageData as Record) + : null; const rawStructuredData = - latestMessage.data && typeof latestMessage.data === 'object' + latestMessage.data && typeof latestMessage.data === "object" ? (latestMessage.data as Record) : null; - const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'session-aborted']; - const isGlobalMessage = globalMessageTypes.includes(String(latestMessage.type)); + const globalMessageTypes = [ + "projects_updated", + "taskmaster-project-updated", + "session-created", + "session-aborted", + "session-status", + "session-accepted", + "session-busy", + "session-state-changed", + ]; + const isGlobalMessage = globalMessageTypes.includes( + String(latestMessage.type), + ); const lifecycleMessageTypes = new Set([ - 'claude-complete', - 'codex-complete', - 'gemini-complete', - 'openrouter-complete', - 'localgpu-complete', - 'cursor-result', - 'session-aborted', - 'claude-error', - 'cursor-error', - 'codex-error', - 'gemini-error', - 'openrouter-error', - 'localgpu-error', - 'session-busy', + "claude-complete", + "codex-complete", + "gemini-complete", + "openrouter-complete", + "localgpu-complete", + "cursor-complete", + "cursor-result", + "session-aborted", + "claude-error", + "cursor-error", + "codex-error", + "gemini-error", + "openrouter-error", + "localgpu-error", ]); const isClaudeSystemInit = - latestMessage.type === 'claude-response' && + latestMessage.type === "claude-response" && structuredMessageData && - structuredMessageData.type === 'system' && - structuredMessageData.subtype === 'init'; + structuredMessageData.type === "system" && + structuredMessageData.subtype === "init"; const isGeminiSystemInit = - latestMessage.type === 'gemini-response' && + latestMessage.type === "gemini-response" && structuredMessageData && - structuredMessageData.type === 'system' && - structuredMessageData.subtype === 'init'; + structuredMessageData.type === "system" && + structuredMessageData.subtype === "init"; const isCursorSystemInit = - latestMessage.type === 'cursor-system' && + latestMessage.type === "cursor-system" && rawStructuredData && - rawStructuredData.type === 'system' && - rawStructuredData.subtype === 'init'; + rawStructuredData.type === "system" && + rawStructuredData.subtype === "init"; - const systemInitSessionId = isClaudeSystemInit || isGeminiSystemInit - ? structuredMessageData?.session_id - : isCursorSystemInit - ? rawStructuredData?.session_id - : null; + const systemInitSessionId = + isClaudeSystemInit || isGeminiSystemInit + ? structuredMessageData?.session_id + : isCursorSystemInit + ? rawStructuredData?.session_id + : null; const activeViewSessionId = - selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; + selectedSession?.id || + currentSessionId || + pendingViewSessionRef.current?.sessionId || + null; + const pendingViewSessionId = pendingViewSessionRef.current?.sessionId || null; + const isPendingViewSession = + Boolean(pendingViewSessionRef.current?.startedAt) && + !selectedSession?.id && + !currentSessionId; + const inferredMessageProvider = (() => { + const messageType = String(latestMessage.type || ""); + if (messageType.startsWith("claude-")) return "claude"; + if (messageType.startsWith("cursor-")) return "cursor"; + if (messageType.startsWith("codex-")) return "codex"; + if (messageType.startsWith("gemini-")) return "gemini"; + if (messageType.startsWith("openrouter-")) return "openrouter"; + if (messageType.startsWith("localgpu-")) return "local"; + if ( + messageType === "session-created" || + messageType === "session-status" || + messageType === "session-aborted" || + messageType === "session-accepted" || + messageType === "session-busy" || + messageType === "session-state-changed" + ) { + return typeof latestMessage.provider === "string" + ? (latestMessage.provider as SessionProvider) + : null; + } + return null; + })(); + const resolveProvider = ( + providerValue?: string | null, + fallback?: SessionProvider | null, + ): SessionProvider => { + const candidate = + typeof providerValue === "string" && providerValue.length > 0 + ? providerValue + : fallback || inferredMessageProvider || provider; + + if (typeof candidate === "string") { + const normalizedCandidate = candidate.trim().toLowerCase(); + if ( + normalizedCandidate && + !isProviderAllowed(normalizedCandidate) && + !warnedUnknownProviders.has(normalizedCandidate) + ) { + warnedUnknownProviders.add(normalizedCandidate); + console.warn( + `[chat] Unknown provider "${candidate}" on message type "${String(latestMessage.type || "")}", falling back to default provider`, + ); + } + } + + return normalizeProvider(candidate as SessionProvider); + }; + const resolveProjectName = ( + projectNameValue?: string | null, + ): string | null => { + if (typeof projectNameValue === "string" && projectNameValue.length > 0) { + return projectNameValue; + } + return selectedProject?.name || selectedSession?.__projectName || null; + }; + const latestMessageProvider = resolveProvider( + typeof latestMessage.provider === "string" ? latestMessage.provider : null, + ); + const latestMessageProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : null, + ); + const activeViewProvider = resolveProvider( + selectedSession?.__provider || provider, + provider, + ); + const activeViewProjectName = + selectedSession?.__projectName || selectedProject?.name || null; + const routedMessageSessionId = + latestMessage.actualSessionId || latestMessage.sessionId || null; + const temporaryActiveSessionId = + activeViewSessionId?.startsWith("new-session-") + ? activeViewSessionId + : null; + const shouldRebindCodexTemporarySession = + Boolean( + temporaryActiveSessionId && + inferredMessageProvider === "codex" && + routedMessageSessionId && + routedMessageSessionId !== temporaryActiveSessionId, + ) && !selectedSession?.id; + + if ( + shouldRebindCodexTemporarySession && + temporaryActiveSessionId && + routedMessageSessionId + ) { + onCodexSessionIdResolved?.( + temporaryActiveSessionId, + routedMessageSessionId, + ); + onReplaceTemporarySession?.( + routedMessageSessionId, + "codex", + latestMessageProjectName, + temporaryActiveSessionId, + ); + + if (pendingViewSessionRef.current?.sessionId === temporaryActiveSessionId) { + pendingViewSessionRef.current = { + ...pendingViewSessionRef.current, + sessionId: routedMessageSessionId, + }; + } + + if (currentSessionId === temporaryActiveSessionId) { + setCurrentSessionId(routedMessageSessionId); + } + } + const isSystemInitForView = - systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId); - const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView); + systemInitSessionId && + (!activeViewSessionId || systemInitSessionId === activeViewSessionId); + const isMessageInActiveScope = ( + sessionId?: string | null, + sessionProvider: SessionProvider = latestMessageProvider, + projectName: string | null = latestMessageProjectName, + ): boolean => { + if (!sessionId || !activeViewSessionId) { + return false; + } + + if (sessionId !== activeViewSessionId) { + return false; + } + + if (sessionProvider !== activeViewProvider) { + return false; + } + + if ( + activeViewProjectName && + projectName && + activeViewProjectName !== projectName + ) { + return false; + } + + return true; + }; + const shouldBypassSessionFilter = + isGlobalMessage || + Boolean(isSystemInitForView) || + Boolean(isPendingViewSession && inferredMessageProvider === provider) || + shouldRebindCodexTemporarySession; const isUnscopedError = !latestMessage.sessionId && pendingViewSessionRef.current && - !pendingViewSessionRef.current.sessionId && - (latestMessage.type === 'claude-error' || - latestMessage.type === 'cursor-error' || - latestMessage.type === 'codex-error' || - latestMessage.type === 'gemini-error'); + (!pendingViewSessionId || + pendingViewSessionId.startsWith("new-session-")) && + (latestMessage.type === "claude-error" || + latestMessage.type === "cursor-error" || + latestMessage.type === "codex-error" || + latestMessage.type === "gemini-error" || + latestMessage.type === "openrouter-error" || + latestMessage.type === "localgpu-error"); + const logFilterDecision = (reason: string, extra: Record = {}) => { + emitSessionFilterDebugLog( + { + reason, + messageType: String(latestMessage.type || ""), + routedSessionId: routedMessageSessionId, + actualSessionId: latestMessage.actualSessionId || null, + sessionProvider: latestMessageProvider, + messageProjectName: latestMessageProjectName, + activeViewSessionId, + activeViewProvider, + activeViewProjectName, + isGlobalMessage, + isPendingViewSession: Boolean(pendingViewSessionRef.current), + shouldRebindCodexTemporarySession, + isUnscopedError: Boolean(isUnscopedError), + shouldBypassSessionFilter: Boolean(shouldBypassSessionFilter), + extra, + }, + sendMessage, + ); + }; + + if (latestMessage.type === "codex-complete") { + const completedSessionId = + latestMessage.sessionId || currentSessionId || null; + const actualSessionId = + latestMessage.actualSessionId || completedSessionId; + if ( + currentSessionId && + currentSessionId.startsWith("new-session-") && + actualSessionId && + currentSessionId !== actualSessionId + ) { + onCodexSessionIdResolved?.(currentSessionId, actualSessionId); + } + if ( + completedSessionId && + actualSessionId && + completedSessionId !== actualSessionId + ) { + onCodexSessionIdResolved?.(completedSessionId, actualSessionId); + } + onCodexTurnSettled?.(actualSessionId || completedSessionId, "complete"); + } else if (latestMessage.type === "codex-error") { + onCodexTurnSettled?.( + routedMessageSessionId || currentSessionId || null, + "error", + ); + } else if ( + latestMessage.type === "session-aborted" && + latestMessage.provider === "codex" + ) { + onCodexTurnSettled?.( + routedMessageSessionId || currentSessionId || null, + "aborted", + ); + } + + if (latestMessage.type === "codex-response" && routedMessageSessionId) { + const codexData = latestMessage.data; + if ( + codexData && + (codexData.type === "turn_started" || + (codexData.type === "item" && codexData.lifecycle === "started")) + ) { + onCodexTurnStarted?.(routedMessageSessionId); + } + } + + const notifySessionProcessing = ( + sessionId?: string | null, + sessionProvider: SessionProvider = latestMessageProvider, + projectName: string | null = latestMessageProjectName, + ) => { + onSessionProcessing?.(sessionId, sessionProvider, projectName); + onSessionStatusResolved?.(sessionId, true); + }; + + const notifySessionCompleted = ( + sessionId?: string | null, + sessionProvider: SessionProvider = latestMessageProvider, + projectName: string | null = latestMessageProjectName, + ) => { + onSessionInactive?.(sessionId, sessionProvider, projectName); + onSessionNotProcessing?.(sessionId, sessionProvider, projectName); + onSessionStatusResolved?.(sessionId, false); + }; + + const clearScopedMessageCache = ( + sessionId?: string | null, + sessionProvider: SessionProvider = latestMessageProvider, + projectName: string | null = latestMessageProjectName, + ) => { + const storageKey = buildChatMessagesStorageKey( + projectName, + sessionId, + sessionProvider, + ); + if (storageKey) { + safeLocalStorage.removeItem(storageKey); + } + }; const handleBackgroundLifecycle = (sessionId?: string) => { if (!sessionId) { return; } clearSessionTimerStart(sessionId); - onSessionInactive?.(sessionId); - onSessionNotProcessing?.(sessionId); - onSessionStatusResolved?.(sessionId, false); + notifySessionCompleted(sessionId, latestMessageProvider, latestMessageProjectName); + }; + + const getLifecycleSessionIds = () => { + const ids: string[] = []; + if (latestMessage.sessionId) { + ids.push(latestMessage.sessionId); + } + + if ( + latestMessage.actualSessionId && + latestMessage.actualSessionId !== latestMessage.sessionId + ) { + ids.push(latestMessage.actualSessionId); + } + + return [...new Set(ids)]; }; - const persistStartTime = (startTime?: number | null, ...sessionIds: Array) => { + const persistStartTime = ( + startTime?: number | null, + ...sessionIds: Array + ) => { if (!Number.isFinite(startTime)) { return; } - const targetSessionId = sessionIds.find((sessionId): sessionId is string => typeof sessionId === 'string' && sessionId.length > 0); + const targetSessionId = sessionIds.find( + (sessionId): sessionId is string => + typeof sessionId === "string" && sessionId.length > 0, + ); if (!targetSessionId) { return; } @@ -459,7 +867,10 @@ export function useChatRealtimeHandlers({ persistSessionTimerStart(targetSessionId, startTime); }; - const syncClaudeStatusStartTime = (startTime?: number | null, fallbackText = 'Processing') => { + const syncClaudeStatusStartTime = ( + startTime?: number | null, + fallbackText = "Processing", + ) => { if (!Number.isFinite(startTime)) { return; } @@ -469,7 +880,8 @@ export function useChatRealtimeHandlers({ setClaudeStatus((prev) => ({ text: prev?.text || fallbackText, tokens: prev?.tokens || 0, - can_interrupt: prev?.can_interrupt !== undefined ? prev.can_interrupt : true, + can_interrupt: + prev?.can_interrupt !== undefined ? prev.can_interrupt : true, startTime: normalizedStartTime, })); }; @@ -487,93 +899,388 @@ export function useChatRealtimeHandlers({ streamTimerRef.current = null; } const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; appendStreamingChunk(setChatMessages, chunk, false); finalizeStreamingMessage(setChatMessages); }; - const markSessionsAsCompleted = (...sessionIds: Array) => { - const normalizedSessionIds = sessionIds.filter((id): id is string => typeof id === 'string' && id.length > 0); + const markSessionsAsCompleted = ( + ...sessionIds: Array + ) => { + const normalizedSessionIds = sessionIds.filter( + (id): id is string => typeof id === "string" && id.length > 0, + ); normalizedSessionIds.forEach((sessionId) => { clearSessionTimerStart(sessionId); - onSessionInactive?.(sessionId); - onSessionNotProcessing?.(sessionId); - onSessionStatusResolved?.(sessionId, false); + notifySessionCompleted( + sessionId, + latestMessageProvider, + latestMessageProjectName, + ); }); }; if (!shouldBypassSessionFilter) { if (!activeViewSessionId) { - if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { - handleBackgroundLifecycle(latestMessage.sessionId); + if (lifecycleMessageTypes.has(String(latestMessage.type))) { + getLifecycleSessionIds().forEach((sessionId) => { + handleBackgroundLifecycle(sessionId); + }); } if (!isUnscopedError) { + logFilterDecision("dropped:no-active-view-session"); return; } } - if (!latestMessage.sessionId && !isUnscopedError) { + if (!routedMessageSessionId && !isUnscopedError) { + logFilterDecision("dropped:missing-session-id"); + return; + } + + if (routedMessageSessionId && activeViewSessionId && routedMessageSessionId !== activeViewSessionId) { + if (lifecycleMessageTypes.has(String(latestMessage.type))) { + getLifecycleSessionIds().forEach((sessionId) => { + handleBackgroundLifecycle(sessionId); + }); + } + logFilterDecision("dropped:session-id-mismatch", { + expectedSessionId: activeViewSessionId, + actualSessionId: routedMessageSessionId, + }); + return; + } + + if (latestMessageProvider !== activeViewProvider) { + if (lifecycleMessageTypes.has(String(latestMessage.type))) { + getLifecycleSessionIds().forEach((sessionId) => { + handleBackgroundLifecycle(sessionId); + }); + } + logFilterDecision("dropped:provider-mismatch", { + expectedProvider: activeViewProvider, + actualProvider: latestMessageProvider, + }); return; } - if (latestMessage.sessionId !== activeViewSessionId) { - if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { - handleBackgroundLifecycle(latestMessage.sessionId); + if ( + activeViewProjectName && + latestMessageProjectName && + activeViewProjectName !== latestMessageProjectName + ) { + if (lifecycleMessageTypes.has(String(latestMessage.type))) { + getLifecycleSessionIds().forEach((sessionId) => { + handleBackgroundLifecycle(sessionId); + }); } + logFilterDecision("dropped:project-mismatch", { + expectedProjectName: activeViewProjectName, + actualProjectName: latestMessageProjectName, + }); return; } } switch (latestMessage.type) { - case 'session-created': - if (latestMessage.sessionId && (!currentSessionId || currentSessionId.startsWith('new-session-'))) { + case "session-accepted": { + const acceptedSessionId = + routedMessageSessionId || + pendingViewSessionRef.current?.sessionId || + currentSessionId || + selectedSession?.id || + null; + const acceptedAt = Number.isFinite(latestMessage.acceptedAt) + ? (latestMessage.acceptedAt as number) + : Date.now(); + const acceptedProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const acceptedProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const isCurrentSession = + !acceptedSessionId || + isMessageInActiveScope( + acceptedSessionId, + acceptedProvider, + acceptedProjectName, + ); + + if (acceptedSessionId) { + persistStartTime( + acceptedAt, + acceptedSessionId, + currentSessionId, + selectedSession?.id, + ); + notifySessionProcessing( + acceptedSessionId, + acceptedProvider, + acceptedProjectName, + ); + } + + if (isCurrentSession) { + setIsLoading(true); + setCanAbortSession(true); + syncClaudeStatusStartTime(acceptedAt, "Processing"); + } + break; + } + + case "session-busy": { + const busySessionId = + routedMessageSessionId || + pendingViewSessionRef.current?.sessionId || + currentSessionId || + selectedSession?.id || + null; + const busyAt = Number.isFinite(latestMessage.reportedAt) + ? (latestMessage.reportedAt as number) + : Date.now(); + const busyProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const busyProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const isCurrentSession = + !busySessionId || + isMessageInActiveScope(busySessionId, busyProvider, busyProjectName); + + if (busySessionId) { + persistStartTime( + busyAt, + busySessionId, + currentSessionId, + selectedSession?.id, + ); + notifySessionProcessing(busySessionId, busyProvider, busyProjectName); + } + + if (busyProvider === "codex") { + onCodexSessionBusy?.(busySessionId); + } + + if (isCurrentSession) { + const busyMessage = String( + latestMessage.message || + "Session is busy. Waiting for the current turn to finish.", + ); + setIsLoading(true); + setCanAbortSession(true); + setStatusTextOverride(busyMessage); + setChatMessages((previous) => { + const lastMessage = previous[previous.length - 1]; + if ( + lastMessage && + lastMessage.type === "assistant" && + String(lastMessage.content || "") === busyMessage + ) { + return previous; + } + return [ + ...previous, + { + type: "assistant", + content: busyMessage, + timestamp: new Date(), + }, + ]; + }); + } + break; + } + + case "session-state-changed": { + const stateSessionId = + typeof routedMessageSessionId === "string" + ? routedMessageSessionId + : null; + if (!stateSessionId) { + break; + } + + const state = String(latestMessage.state || "").toLowerCase(); + const stateProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const stateProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const isCurrentSession = + isMessageInActiveScope( + stateSessionId, + stateProvider, + stateProjectName, + ); + const isProcessingState = + state === "running" || + state === "queued" || + state === "in_progress" || + state === "waiting_user"; + const isTerminalState = + state === "completed" || + state === "failed" || + state === "aborted" || + state === "error" || + state === "idle"; + + if (isProcessingState) { + notifySessionProcessing(stateSessionId, stateProvider, stateProjectName); + if (isCurrentSession) { + setIsLoading(true); + setCanAbortSession(true); + } + break; + } + + if (isTerminalState) { + clearSessionTimerStart(stateSessionId); + notifySessionCompleted(stateSessionId, stateProvider, stateProjectName); + if (isCurrentSession) { + clearLoadingIndicators(); + } + } + break; + } + + case "session-created": + if ( + latestMessage.sessionId && + (!currentSessionId || currentSessionId.startsWith("new-session-")) + ) { const createdSessionProvider = - (latestMessage.provider as SessionProvider | undefined) || provider; + resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const explicitProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : null, + ); + const createdProjectName = + explicitProjectName + || (pendingViewSessionRef.current ? selectedProject?.name || null : null); const pendingStartTime = pendingViewSessionRef.current?.startedAt; - const temporarySessionId = currentSessionId?.startsWith('new-session-') ? currentSessionId : null; + const pendingTemporarySessionId = pendingViewSessionRef.current + ?.sessionId?.startsWith("new-session-") + ? pendingViewSessionRef.current.sessionId + : null; + const temporarySessionId = currentSessionId?.startsWith( + "new-session-", + ) + ? currentSessionId + : pendingTemporarySessionId; if (temporarySessionId) { moveSessionTimerStart(temporarySessionId, latestMessage.sessionId); + if (createdSessionProvider === "codex") { + onCodexSessionIdResolved?.( + temporarySessionId, + latestMessage.sessionId, + ); + } } persistStartTime( - typeof latestMessage.startTime === 'number' ? latestMessage.startTime : pendingStartTime, + typeof latestMessage.startTime === "number" + ? latestMessage.startTime + : pendingStartTime, latestMessage.sessionId, ); - if (selectedProject && latestMessage.mode) { - safeLocalStorage.setItem(`session_mode_${selectedProject.name}_${latestMessage.sessionId}`, String(latestMessage.mode)); + if (createdProjectName && latestMessage.mode) { + safeLocalStorage.setItem( + `session_mode_${createdProjectName}_${latestMessage.sessionId}`, + String(latestMessage.mode), + ); } - sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); - if ((latestMessage as any).provider === 'gemini') { - sessionStorage.setItem('geminiSessionId', latestMessage.sessionId); - } else if (latestMessage.model) { - sessionStorage.setItem('cursorSessionId', latestMessage.sessionId); + persistScopedPendingSessionId( + createdProjectName, + createdSessionProvider, + latestMessage.sessionId, + ); + if ( + createdSessionProvider === "gemini" || + createdSessionProvider === "cursor" + ) { + persistScopedProviderSessionId( + createdProjectName, + createdSessionProvider, + latestMessage.sessionId, + ); } - if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { + if ( + pendingViewSessionRef.current && + (!pendingViewSessionRef.current.sessionId || + pendingViewSessionRef.current.sessionId.startsWith( + "new-session-", + )) + ) { pendingViewSessionRef.current.sessionId = latestMessage.sessionId; } setIsSystemSessionChange(true); - onReplaceTemporarySession?.(latestMessage.sessionId); - onNavigateToSession?.(latestMessage.sessionId, createdSessionProvider, selectedProject?.name); + onReplaceTemporarySession?.( + latestMessage.sessionId, + createdSessionProvider, + createdProjectName, + temporarySessionId, + ); + if (createdProjectName || pendingViewSessionRef.current) { + onNavigateToSession?.( + latestMessage.sessionId, + createdSessionProvider, + createdProjectName || undefined, + ); + } setPendingPermissionRequests((previous) => previous.map((request) => - request.sessionId ? request : { ...request, sessionId: latestMessage.sessionId }, + request.sessionId + ? request + : { ...request, sessionId: latestMessage.sessionId }, ), ); } break; - case 'token-budget': + case "token-budget": if (latestMessage.data) { setTokenBudget(latestMessage.data); } break; - case 'claude-response': { - if (messageData && typeof messageData === 'object' && messageData.type) { + case "claude-response": { + if ( + messageData && + typeof messageData === "object" && + messageData.type + ) { if (Number.isFinite(messageData.startTime)) { - persistStartTime(messageData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + messageData.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(messageData.startTime); } - if (messageData.type === 'content_block_delta' && messageData.delta?.text) { + if ( + messageData.type === "content_block_delta" && + messageData.delta?.text + ) { setIsLoading(true); setStatusTextOverride(null); const decodedText = decodeHtmlEntities(messageData.delta.text); @@ -581,54 +1288,91 @@ export function useChatRealtimeHandlers({ if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, false); }, 30); } return; } - if (messageData.type === 'content_block_stop') { + if (messageData.type === "content_block_stop") { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; appendStreamingChunk(setChatMessages, chunk, false); finalizeStreamingMessage(setChatMessages); return; } } - if (isClaudeSystemInit && structuredMessageData?.session_id && isSystemInitForView) { - if (!currentSessionId || structuredMessageData.session_id !== currentSessionId) { - console.log('Claude CLI session duplication or new init detected'); + if ( + isClaudeSystemInit && + structuredMessageData?.session_id && + isSystemInitForView + ) { + if ( + !currentSessionId || + structuredMessageData.session_id !== currentSessionId + ) { setIsSystemSessionChange(true); - onNavigateToSession?.(structuredMessageData.session_id, 'claude', selectedProject?.name); + onNavigateToSession?.( + structuredMessageData.session_id, + "claude", + latestMessageProjectName || undefined, + ); return; } } - if (structuredMessageData && Array.isArray(structuredMessageData.content) && structuredMessageData.role === 'assistant') { - handleStructuredAssistantMessage(structuredMessageData, rawStructuredData); - } else if (structuredMessageData && structuredMessageData.role === 'assistant' && typeof structuredMessageData.content === 'string' && structuredMessageData.content.trim()) { + if ( + structuredMessageData && + Array.isArray(structuredMessageData.content) && + structuredMessageData.role === "assistant" + ) { + handleStructuredAssistantMessage( + structuredMessageData, + rawStructuredData, + ); + } else if ( + structuredMessageData && + structuredMessageData.role === "assistant" && + typeof structuredMessageData.content === "string" && + structuredMessageData.content.trim() + ) { handleSimpleAssistantMessage(structuredMessageData); } - if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) { + if ( + structuredMessageData?.role === "user" && + Array.isArray(structuredMessageData.content) + ) { handleUserToolResults(structuredMessageData, rawStructuredData); } break; } - case 'gemini-response': { - if (messageData && typeof messageData === 'object' && messageData.type) { + case "gemini-response": { + if ( + messageData && + typeof messageData === "object" && + messageData.type + ) { if (Number.isFinite(messageData.startTime)) { - persistStartTime(messageData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + messageData.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(messageData.startTime); } - if (messageData.type === 'content_block_delta' && messageData.delta?.text) { + if ( + messageData.type === "content_block_delta" && + messageData.delta?.text + ) { setIsLoading(true); setStatusTextOverride(null); const decodedText = decodeHtmlEntities(messageData.delta.text); @@ -636,57 +1380,87 @@ export function useChatRealtimeHandlers({ if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, false); }, 30); } return; } - if (messageData.type === 'content_block_stop') { + if (messageData.type === "content_block_stop") { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; appendStreamingChunk(setChatMessages, chunk, false); finalizeStreamingMessage(setChatMessages); return; } } - if (isGeminiSystemInit && structuredMessageData?.session_id && isSystemInitForView) { - if (!currentSessionId || structuredMessageData.session_id !== currentSessionId) { - console.log('Gemini CLI session init detected'); + if ( + isGeminiSystemInit && + structuredMessageData?.session_id && + isSystemInitForView + ) { + if ( + !currentSessionId || + structuredMessageData.session_id !== currentSessionId + ) { setIsSystemSessionChange(true); - onNavigateToSession?.(structuredMessageData.session_id, 'gemini', selectedProject?.name); + onNavigateToSession?.( + structuredMessageData.session_id, + "gemini", + latestMessageProjectName || undefined, + ); return; } } - if (structuredMessageData && Array.isArray(structuredMessageData.content) && structuredMessageData.role === 'assistant') { - handleStructuredAssistantMessage(structuredMessageData, rawStructuredData); - } else if (structuredMessageData && structuredMessageData.role === 'assistant' && typeof structuredMessageData.content === 'string' && structuredMessageData.content.trim()) { + if ( + structuredMessageData && + Array.isArray(structuredMessageData.content) && + structuredMessageData.role === "assistant" + ) { + handleStructuredAssistantMessage( + structuredMessageData, + rawStructuredData, + ); + } else if ( + structuredMessageData && + structuredMessageData.role === "assistant" && + typeof structuredMessageData.content === "string" && + structuredMessageData.content.trim() + ) { handleSimpleAssistantMessage(structuredMessageData); } - if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) { + if ( + structuredMessageData?.role === "user" && + Array.isArray(structuredMessageData.content) + ) { handleUserToolResults(structuredMessageData, rawStructuredData); } break; } - case 'localgpu-response': - case 'openrouter-response': { + case "localgpu-response": + case "openrouter-response": { const orData = latestMessage.data; - if (orData && typeof orData === 'object') { + if (orData && typeof orData === "object") { if (Number.isFinite(orData.startTime)) { - persistStartTime(orData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + orData.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(orData.startTime); } - if (orData.type === 'assistant_message' && orData.message?.content) { + if (orData.type === "assistant_message" && orData.message?.content) { setIsLoading(true); setStatusTextOverride(null); const text = orData.message.content; @@ -694,7 +1468,7 @@ export function useChatRealtimeHandlers({ if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, false); }, 30); @@ -702,28 +1476,32 @@ export function useChatRealtimeHandlers({ return; } - if (orData.type === 'structured_turn' && orData.message) { + if (orData.type === "structured_turn" && orData.message) { flushAndFinalizePendingStream(); handleStructuredAssistantMessage(orData.message, orData); return; } - if (orData.type === 'structured_result' && orData.message) { + if (orData.type === "structured_result" && orData.message) { handleUserToolResults(orData.message, orData); return; } - if (orData.type === 'tool_use') { + if (orData.type === "tool_use") { flushAndFinalizePendingStream(); - if (['Bash', 'bash', 'run_shell_command'].includes(orData.toolName)) { - setStatusTextOverride(i18n.t('chat:status.runningCode')); + if ( + ["Bash", "bash", "run_shell_command"].includes(orData.toolName) + ) { + setStatusTextOverride(i18n.t("chat:status.runningCode")); } - const toolInput = orData.toolInput ? JSON.stringify(orData.toolInput, null, 2) : ''; + const toolInput = orData.toolInput + ? JSON.stringify(orData.toolInput, null, 2) + : ""; setChatMessages((prev) => [ ...prev, { - type: 'assistant' as const, - content: '', + type: "assistant" as const, + content: "", timestamp: new Date(), isToolUse: true, toolName: orData.toolName, @@ -735,12 +1513,15 @@ export function useChatRealtimeHandlers({ return; } - if (orData.type === 'tool_result') { + if (orData.type === "tool_result") { setStatusTextOverride(null); setChatMessages((prev) => { const updated = [...prev]; for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].isToolUse && updated[i].toolId === orData.toolCallId) { + if ( + updated[i].isToolUse && + updated[i].toolId === orData.toolCallId + ) { updated[i] = { ...updated[i], toolResult: { @@ -760,14 +1541,16 @@ export function useChatRealtimeHandlers({ break; } - case 'claude-output': { - const cleaned = String(latestMessage.data || ''); + case "claude-output": { + const cleaned = String(latestMessage.data || ""); if (cleaned.trim()) { - streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned; + streamBufferRef.current += streamBufferRef.current + ? `\n${cleaned}` + : cleaned; if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, true); }, 30); @@ -776,30 +1559,51 @@ export function useChatRealtimeHandlers({ break; } - case 'claude-complete': - case 'gemini-complete': - case 'openrouter-complete': - case 'localgpu-complete': { - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - const completedSessionId = latestMessage.sessionId || currentSessionId || pendingSessionId; + case "claude-complete": + case "cursor-complete": + case "gemini-complete": + case "openrouter-complete": + case "localgpu-complete": { + const pendingSessionId = readScopedPendingSessionId( + latestMessageProjectName, + latestMessageProvider, + ); + const completedSessionId = + latestMessage.sessionId || currentSessionId || pendingSessionId; flushAndFinalizePendingStream(); clearLoadingIndicators(); - markSessionsAsCompleted(completedSessionId, currentSessionId, selectedSession?.id, pendingSessionId); - if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { + markSessionsAsCompleted( + completedSessionId, + currentSessionId, + selectedSession?.id, + pendingSessionId, + ); + if ( + pendingSessionId && + !currentSessionId && + latestMessage.exitCode === 0 + ) { setCurrentSessionId(pendingSessionId); - sessionStorage.removeItem('pendingSessionId'); + clearScopedPendingSessionId( + latestMessageProjectName, + latestMessageProvider, + ); } - if (selectedProject && latestMessage.exitCode === 0) { - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); + if (latestMessage.exitCode === 0) { + clearScopedMessageCache( + completedSessionId || pendingSessionId, + latestMessageProvider, + latestMessageProjectName, + ); } setPendingPermissionRequests([]); break; } - case 'claude-error': - case 'gemini-error': - case 'openrouter-error': - case 'localgpu-error': { + case "claude-error": + case "gemini-error": + case "openrouter-error": + case "localgpu-error": { if (isLegacyTaskMasterInstallError(latestMessage.error)) { break; } @@ -811,28 +1615,45 @@ export function useChatRealtimeHandlers({ null; flushAndFinalizePendingStream(); clearLoadingIndicators(); - markSessionsAsCompleted(erroredSessionId, currentSessionId, selectedSession?.id); - // Clear pendingSessionId for the errored session (not all sessions — other tabs may be active) - if (typeof window !== 'undefined') { - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - if (pendingSessionId && (!erroredSessionId || pendingSessionId === erroredSessionId)) { - sessionStorage.removeItem('pendingSessionId'); - } + markSessionsAsCompleted( + erroredSessionId, + currentSessionId, + selectedSession?.id, + ); + // Clear pendingSessionId for the errored session (not all sessions 鈥?other tabs may be active) + const pendingSessionId = readScopedPendingSessionId( + latestMessageProjectName, + latestMessageProvider, + ); + if ( + pendingSessionId && + (!erroredSessionId || pendingSessionId === erroredSessionId) + ) { + clearScopedPendingSessionId( + latestMessageProjectName, + latestMessageProvider, + ); } setPendingPermissionRequests([]); - const details = typeof latestMessage.details === 'string' ? latestMessage.details.trim() : ''; + const details = + typeof latestMessage.details === "string" + ? latestMessage.details.trim() + : ""; const errorContent = details ? `Error: ${latestMessage.error}\n\n
Technical details\n\n\`\`\`text\n${details.slice(0, 8000)}\n\`\`\`\n
` : `Error: ${latestMessage.error}`; setChatMessages((previous) => { const last = previous[previous.length - 1]; - if (last?.type === 'error' && String(last.content || '') === errorContent) { + if ( + last?.type === "error" && + String(last.content || "") === errorContent + ) { return previous; } return [ ...previous, { - type: 'error', + type: "error", content: errorContent, timestamp: new Date(), errorType: latestMessage.errorType, @@ -843,27 +1664,39 @@ export function useChatRealtimeHandlers({ break; } - case 'cursor-system': + case "cursor-system": try { const cursorData = latestMessage.data; - if (cursorData && cursorData.type === 'system' && cursorData.subtype === 'init' && cursorData.session_id) { + if ( + cursorData && + cursorData.type === "system" && + cursorData.subtype === "init" && + cursorData.session_id + ) { if (!isSystemInitForView) return; - if (!currentSessionId || cursorData.session_id !== currentSessionId) { + if ( + !currentSessionId || + cursorData.session_id !== currentSessionId + ) { setIsSystemSessionChange(true); - onNavigateToSession?.(cursorData.session_id, 'cursor', selectedProject?.name); + onNavigateToSession?.( + cursorData.session_id, + "cursor", + latestMessageProjectName || undefined, + ); } } } catch (error) { - console.warn('Error handling cursor-system message:', error); + console.warn("Error handling cursor-system message:", error); } break; - case 'cursor-tool-use': + case "cursor-tool-use": setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''}`, + type: "assistant", + content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ""}`, timestamp: new Date(), isToolUse: true, toolName: latestMessage.tool, @@ -872,127 +1705,190 @@ export function useChatRealtimeHandlers({ ]); break; - case 'cursor-error': + case "cursor-error": if (isLegacyTaskMasterInstallError(latestMessage.error)) break; flushAndFinalizePendingStream(); clearLoadingIndicators(); - markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); + markSessionsAsCompleted( + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); setPendingPermissionRequests([]); setChatMessages((previous) => [ ...previous, - { type: 'error', content: `Cursor error: ${latestMessage.error || 'Unknown error'}`, timestamp: new Date(), errorType: latestMessage.errorType, isRetryable: latestMessage.isRetryable === true }, + { + type: "error", + content: `Cursor error: ${latestMessage.error || "Unknown error"}`, + timestamp: new Date(), + errorType: latestMessage.errorType, + isRetryable: latestMessage.isRetryable === true, + }, ]); break; - case 'cursor-result': { - const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; - const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); - + case "cursor-result": { + const cursorCompletedSessionId = + latestMessage.sessionId || currentSessionId; + const pendingCursorSessionId = + readScopedPendingSessionId(latestMessageProjectName, "cursor"); + if (Number.isFinite(latestMessage.startTime)) { - persistStartTime(latestMessage.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + latestMessage.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(latestMessage.startTime); } clearLoadingIndicators(); - markSessionsAsCompleted(cursorCompletedSessionId, currentSessionId, selectedSession?.id, pendingCursorSessionId); + markSessionsAsCompleted( + cursorCompletedSessionId, + currentSessionId, + selectedSession?.id, + pendingCursorSessionId, + ); try { const resultData = latestMessage.data || {}; - const textResult = typeof resultData.result === 'string' ? resultData.result : ''; + const textResult = + typeof resultData.result === "string" ? resultData.result : ""; if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } const pendingChunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; setChatMessages((previous) => { const updated = [...previous]; const lastIndex = updated.length - 1; const last = updated[lastIndex]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - const finalContent = textResult && textResult.trim() ? textResult : `${last.content || ''}${pendingChunk || ''}`; - updated[lastIndex] = { ...last, content: finalContent, isStreaming: false }; + if ( + last && + last.type === "assistant" && + !last.isToolUse && + last.isStreaming + ) { + const finalContent = + textResult && textResult.trim() + ? textResult + : `${last.content || ""}${pendingChunk || ""}`; + updated[lastIndex] = { + ...last, + content: finalContent, + isStreaming: false, + }; } else if (textResult && textResult.trim()) { - updated.push({ type: resultData.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false }); + updated.push({ + type: resultData.is_error ? "error" : "assistant", + content: textResult, + timestamp: new Date(), + isStreaming: false, + }); } return updated; }); } catch (error) { - console.warn('Error handling cursor-result message:', error); + console.warn("Error handling cursor-result message:", error); } - if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) { + if ( + cursorCompletedSessionId && + !currentSessionId && + cursorCompletedSessionId === pendingCursorSessionId + ) { setCurrentSessionId(cursorCompletedSessionId); - sessionStorage.removeItem('pendingSessionId'); - if (window.refreshProjects) setTimeout(() => window.refreshProjects?.(), 500); + clearScopedPendingSessionId(latestMessageProjectName, "cursor"); + if (window.refreshProjects) + setTimeout(() => window.refreshProjects?.(), 500); } break; } - case 'cursor-output': + case "cursor-output": try { if (Number.isFinite(latestMessage.startTime)) { - persistStartTime(latestMessage.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + latestMessage.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(latestMessage.startTime); } setIsLoading(true); - const raw = String(latestMessage.data ?? ''); + const raw = String(latestMessage.data ?? ""); const cleaned = raw - .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') + .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "") .trim(); if (cleaned) { - streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned; + streamBufferRef.current += streamBufferRef.current + ? `\n${cleaned}` + : cleaned; if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, true); }, 100); } } } catch (error) { - console.warn('Error handling cursor-output message:', error); + console.warn("Error handling cursor-output message:", error); } break; - case 'codex-response': { + case "codex-response": { const codexData = latestMessage.data; if (!codexData) break; if (Number.isFinite(codexData.startTime)) { - persistStartTime(codexData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + codexData.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(codexData.startTime); } setIsLoading(true); - if (codexData.type === 'item') { + if (codexData.type === "item") { const itemId = codexData.itemId; const lifecycle = codexData.lifecycle; // 'started' | 'completed' | 'other' switch (codexData.itemType) { - case 'agent_message': + case "agent_message": if (codexData.message?.content?.trim()) { const content = decodeHtmlEntities(codexData.message.content); // Server marks system prompts; also detect on frontend as fallback - const isSystemPrompt = codexData.isSystemPrompt || + const isSystemPrompt = + codexData.isSystemPrompt || /^#\s+(AGENTS|SKILL|INSTRUCTIONS)/m.test(content) || - content.includes('') || - content.includes('') || + content.includes("") || + content.includes("") || /^#+\s+.*instructions\s+for\s+\//im.test(content) || - (content.includes('Base directory for this skill:') && content.length > 500) || - (content.length > 2000 && /^\d+\)\s/m.test(content) && /\bskill\b/i.test(content)) || - ((content.match(/SKILL\.md\)/g) || []).length >= 3) || - content.includes('### How to use skills') || - content.includes('## How to use skills') || - (content.includes('Trigger rules:') && content.includes('skill') && content.length > 500); + (content.includes("Base directory for this skill:") && + content.length > 500) || + (content.length > 2000 && + /^\d+\)\s/m.test(content) && + /\bskill\b/i.test(content)) || + (content.match(/SKILL\.md\)/g) || []).length >= 3 || + content.includes("### How to use skills") || + content.includes("## How to use skills") || + (content.includes("Trigger rules:") && + content.includes("skill") && + content.length > 500); if (isSystemPrompt) { // Show as collapsed skill content setChatMessages((previous) => [ ...previous, { - type: 'user', + type: "user", content, timestamp: new Date(), isSkillContent: true, @@ -1002,7 +1898,7 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', + type: "assistant", content, timestamp: new Date(), }, @@ -1011,14 +1907,14 @@ export function useChatRealtimeHandlers({ } break; - case 'reasoning': + case "reasoning": // Codex reasoning items are very brief status notes (e.g. "Planning API path inspection") // They add noise without value - skip them entirely for Codex sessions break; - case 'command_execution': - if (lifecycle !== 'completed') { - setStatusTextOverride(i18n.t('chat:status.runningCode')); + case "command_execution": + if (lifecycle !== "completed") { + setStatusTextOverride(i18n.t("chat:status.runningCode")); } else { setStatusTextOverride(null); } @@ -1028,7 +1924,7 @@ export function useChatRealtimeHandlers({ // Wrap command in object format expected by Bash ToolRenderer const bashToolInput = { command: codexData.command }; - if (lifecycle === 'completed' && itemId) { + if (lifecycle === "completed" && itemId) { // Update existing tool message if it was added on 'started' setChatMessages((previous) => { const existingIdx = previous.findIndex( @@ -1038,10 +1934,13 @@ export function useChatRealtimeHandlers({ const updated = [...previous]; updated[existingIdx] = { ...updated[existingIdx], - toolResult: output != null ? { - content: output, - isError: exitCode != null && exitCode !== 0, - } : null, + toolResult: + output != null + ? { + content: output, + isError: exitCode != null && exitCode !== 0, + } + : null, exitCode, }; return updated; @@ -1050,16 +1949,19 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'Bash', + toolName: "Bash", toolInput: bashToolInput, - toolResult: output != null ? { - content: output, - isError: exitCode != null && exitCode !== 0, - } : null, + toolResult: + output != null + ? { + content: output, + isError: exitCode != null && exitCode !== 0, + } + : null, exitCode, codexItemId: itemId, }, @@ -1070,16 +1972,19 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'Bash', + toolName: "Bash", toolInput: bashToolInput, - toolResult: output != null ? { - content: output, - isError: exitCode != null && exitCode !== 0, - } : null, + toolResult: + output != null + ? { + content: output, + isError: exitCode != null && exitCode !== 0, + } + : null, exitCode, codexItemId: itemId, }, @@ -1088,13 +1993,16 @@ export function useChatRealtimeHandlers({ } break; - case 'file_change': + case "file_change": if (codexData.changes?.length > 0) { const changesList = codexData.changes - .map((change: { kind: string; path: string }) => `${change.kind}: ${change.path}`) - .join('\n'); + .map( + (change: { kind: string; path: string }) => + `${change.kind}: ${change.path}`, + ) + .join("\n"); - if (lifecycle === 'completed' && itemId) { + if (lifecycle === "completed" && itemId) { setChatMessages((previous) => { const existingIdx = previous.findIndex( (m) => m.codexItemId === itemId && m.isToolUse, @@ -1114,11 +2022,11 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'FileChanges', + toolName: "FileChanges", toolInput: changesList, toolResult: { content: `Status: ${codexData.status}`, @@ -1132,16 +2040,18 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'FileChanges', + toolName: "FileChanges", toolInput: changesList, - toolResult: codexData.status ? { - content: `Status: ${codexData.status}`, - isError: false, - } : null, + toolResult: codexData.status + ? { + content: `Status: ${codexData.status}`, + isError: false, + } + : null, codexItemId: itemId, }, ]); @@ -1149,14 +2059,17 @@ export function useChatRealtimeHandlers({ } break; - case 'mcp_tool_call': { + case "mcp_tool_call": { const toolResult = codexData.result - ? { content: JSON.stringify(codexData.result, null, 2), isError: false } + ? { + content: JSON.stringify(codexData.result, null, 2), + isError: false, + } : codexData.error?.message - ? { content: codexData.error.message, isError: true } - : null; + ? { content: codexData.error.message, isError: true } + : null; - if (lifecycle === 'completed' && itemId) { + if (lifecycle === "completed" && itemId) { setChatMessages((previous) => { const existingIdx = previous.findIndex( (m) => m.codexItemId === itemId && m.isToolUse, @@ -1172,8 +2085,8 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, toolName: `${codexData.server}:${codexData.tool}`, @@ -1187,8 +2100,8 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, toolName: `${codexData.server}:${codexData.tool}`, @@ -1201,9 +2114,9 @@ export function useChatRealtimeHandlers({ break; } - case 'web_search': { - const query = codexData.query || 'Searching...'; - if (lifecycle === 'completed' && itemId) { + case "web_search": { + const query = codexData.query || "Searching..."; + if (lifecycle === "completed" && itemId) { // Update existing or add new setChatMessages((previous) => { const existingIdx = previous.findIndex( @@ -1216,11 +2129,11 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'WebSearch', + toolName: "WebSearch", toolInput: { command: query }, toolResult: null, codexItemId: itemId, @@ -1231,11 +2144,11 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'WebSearch', + toolName: "WebSearch", toolInput: { command: query }, toolResult: null, codexItemId: itemId, @@ -1245,12 +2158,12 @@ export function useChatRealtimeHandlers({ break; } - case 'error': + case "error": if (codexData.message?.content) { setChatMessages((previous) => [ ...previous, { - type: 'error', + type: "error", content: codexData.message.content, timestamp: new Date(), }, @@ -1259,97 +2172,226 @@ export function useChatRealtimeHandlers({ break; default: - console.log('[Codex] Unhandled item type:', codexData.itemType, codexData); + console.log( + "[Codex] Unhandled item type:", + codexData.itemType, + codexData, + ); } } - if (codexData.type === 'turn_complete' || codexData.type === 'turn_failed') { + if ( + codexData.type === "turn_complete" || + codexData.type === "turn_failed" + ) { clearLoadingIndicators(); - markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); - if (codexData.type === 'turn_failed') { - setChatMessages((previous) => [...previous, { type: 'error', content: codexData.error?.message || 'Turn failed', timestamp: new Date() }]); + markSessionsAsCompleted( + routedMessageSessionId, + currentSessionId, + selectedSession?.id, + ); + if (codexData.type === "turn_failed") { + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: codexData.error?.message || "Turn failed", + timestamp: new Date(), + }, + ]); } } break; } - case 'codex-complete': { - const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); - const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId; - const codexCompletedSessionId = latestMessage.sessionId || currentSessionId || codexPendingSessionId; + case "codex-complete": { + const codexPendingSessionId = + readScopedPendingSessionId(latestMessageProjectName, "codex"); + const codexActualSessionId = + latestMessage.actualSessionId || + codexPendingSessionId || + routedMessageSessionId; + const codexCompletedSessionId = + routedMessageSessionId || currentSessionId || codexPendingSessionId; clearLoadingIndicators(); - markSessionsAsCompleted(codexCompletedSessionId, codexActualSessionId, currentSessionId, selectedSession?.id, codexPendingSessionId); - if (codexPendingSessionId && !currentSessionId) { - setCurrentSessionId(codexActualSessionId); + markSessionsAsCompleted( + codexCompletedSessionId, + codexActualSessionId, + currentSessionId, + selectedSession?.id, + codexPendingSessionId, + ); + + const shouldSyncToActualSessionId = + Boolean(codexActualSessionId) && + codexActualSessionId !== currentSessionId && + ((currentSessionId && currentSessionId.startsWith("new-session-")) || + Boolean(codexPendingSessionId)); + + if (shouldSyncToActualSessionId) { + setCurrentSessionId(codexActualSessionId || null); setIsSystemSessionChange(true); if (codexActualSessionId) { - onNavigateToSession?.(codexActualSessionId, 'codex', selectedProject?.name); + onNavigateToSession?.( + codexActualSessionId, + "codex", + latestMessageProjectName || undefined, + ); } - sessionStorage.removeItem('pendingSessionId'); } - if (selectedProject) safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); + + if (codexPendingSessionId) { + clearScopedPendingSessionId(latestMessageProjectName, "codex"); + } + + clearScopedMessageCache( + codexCompletedSessionId || codexActualSessionId, + "codex", + latestMessageProjectName, + ); break; } - case 'codex-error': + case "codex-error": if (isLegacyTaskMasterInstallError(latestMessage.error)) break; flushAndFinalizePendingStream(); clearLoadingIndicators(); - markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); + markSessionsAsCompleted( + routedMessageSessionId, + currentSessionId, + selectedSession?.id, + ); setPendingPermissionRequests([]); - setChatMessages((previous) => [...previous, { type: 'error', content: latestMessage.error || 'An error occurred with Codex', timestamp: new Date(), errorType: latestMessage.errorType, isRetryable: latestMessage.isRetryable === true }]); + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: latestMessage.error || "An error occurred with Codex", + timestamp: new Date(), + errorType: latestMessage.errorType, + isRetryable: latestMessage.isRetryable === true, + }, + ]); break; - case 'session-aborted': { - const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; - const abortedSessionId = latestMessage.sessionId || currentSessionId; + case "session-aborted": { + const abortedProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const abortedProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const pendingSessionId = readScopedPendingSessionId( + abortedProjectName, + abortedProvider, + ); + const abortedSessionId = routedMessageSessionId || currentSessionId; if (latestMessage.success !== false) { clearLoadingIndicators(); - markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId); - if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) sessionStorage.removeItem('pendingSessionId'); + markSessionsAsCompleted( + abortedSessionId, + currentSessionId, + selectedSession?.id, + pendingSessionId, + ); + if ( + pendingSessionId && + (!abortedSessionId || pendingSessionId === abortedSessionId) + ) + clearScopedPendingSessionId(abortedProjectName, abortedProvider); setPendingPermissionRequests([]); - setChatMessages((previous) => [...previous, { type: 'assistant', content: 'Session interrupted by user.', timestamp: new Date() }]); + setChatMessages((previous) => [ + ...previous, + { + type: "assistant", + content: "Session interrupted by user.", + timestamp: new Date(), + }, + ]); } else { clearLoadingIndicators(); setPendingPermissionRequests([]); - setChatMessages((previous) => [...previous, { type: 'error', content: 'Session has already finished.', timestamp: new Date() }]); + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: "Session has already finished.", + timestamp: new Date(), + }, + ]); } break; } - case 'session-busy': - console.warn(`[session-busy] Session ${latestMessage.sessionId} is already processing (${latestMessage.provider})`); - setChatMessages((previous) => { - const busyMsg = 'This session is still processing. Please wait for the current response to complete.'; - const last = previous[previous.length - 1]; - if (last?.type === 'error' && last.content === busyMsg) return previous; - return [...previous, { type: 'error', content: busyMsg, timestamp: new Date() }]; - }); - break; + case "session-status": { + const statusSessionId = routedMessageSessionId; + if (!statusSessionId) { + break; + } + + const statusProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const statusProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const isCurrentSession = isMessageInActiveScope( + statusSessionId, + statusProvider, + statusProjectName, + ); + if (latestMessage.isProcessing) { + persistStartTime( + latestMessage.startTime, + statusSessionId, + currentSessionId, + selectedSession?.id, + ); + notifySessionProcessing( + statusSessionId, + statusProvider, + statusProjectName, + ); + + if (!isCurrentSession) { + break; + } - case 'session-status': { - const statusSessionId = latestMessage.sessionId; - const isCurrentSession = statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); - if (isCurrentSession && latestMessage.isProcessing) { - persistStartTime(latestMessage.startTime, statusSessionId, currentSessionId, selectedSession?.id); setIsLoading(true); setCanAbortSession(true); - onSessionProcessing?.(statusSessionId); - onSessionStatusResolved?.(statusSessionId, true); // If we have a startTime from the backend, sync our status if (Number.isFinite(latestMessage.startTime)) { - syncClaudeStatusStartTime(latestMessage.startTime, RESUMING_STATUS_TEXT); + syncClaudeStatusStartTime( + latestMessage.startTime, + RESUMING_STATUS_TEXT, + ); } - } else if (isCurrentSession && latestMessage.isProcessing === false) { + } else if (latestMessage.isProcessing === false) { clearSessionTimerStart(statusSessionId); + notifySessionCompleted( + statusSessionId, + statusProvider, + statusProjectName, + ); + + if (!isCurrentSession) { + break; + } + clearLoadingIndicators(); - onSessionNotProcessing?.(statusSessionId); - onSessionStatusResolved?.(statusSessionId, false); } break; } - case 'claude-permission-request': { + case "claude-permission-request": { const { requestId, toolName, input: toolInput } = latestMessage; if (!requestId || !toolName) break; @@ -1361,43 +2403,58 @@ export function useChatRealtimeHandlers({ requestId, toolName, input: toolInput, - sessionId: latestMessage.sessionId || currentSessionId, + sessionId: routedMessageSessionId || currentSessionId, receivedAt: new Date(), }, ]; }); - + // Ensure UI is in loading/waiting state setIsLoading(true); setCanAbortSession(true); break; } - case 'claude-permission-cancelled': { + case "claude-permission-cancelled": { const { requestId } = latestMessage; if (!requestId) break; - setPendingPermissionRequests((previous) => previous.filter((p) => p.requestId !== requestId)); + setPendingPermissionRequests((previous) => + previous.filter((p) => p.requestId !== requestId), + ); break; } - case 'claude-status': - case 'gemini-status': { + case "claude-status": + case "gemini-status": { const statusData = latestMessage.data; if (!statusData) break; - persistStartTime(statusData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); - const statusInfo = { - text: statusData.message || statusData.status || (typeof statusData === 'string' ? statusData : 'Working...'), - tokens: statusData.tokens || statusData.token_count || 0, - can_interrupt: statusData.can_interrupt !== undefined ? statusData.can_interrupt : true, - startTime: statusData.startTime // Use startTime from message if provided + persistStartTime( + statusData.startTime, + routedMessageSessionId, + currentSessionId, + selectedSession?.id, + ); + const statusInfo = { + text: + statusData.message || + statusData.status || + (typeof statusData === "string" ? statusData : "Working..."), + tokens: statusData.tokens || statusData.token_count || 0, + can_interrupt: + statusData.can_interrupt !== undefined + ? statusData.can_interrupt + : true, + startTime: statusData.startTime, // Use startTime from message if provided }; - + // Use updater function to preserve existing startTime if not provided in message - setClaudeStatus(prev => ({ + setClaudeStatus((prev) => ({ ...statusInfo, - startTime: Number.isFinite(statusInfo.startTime) ? statusInfo.startTime : prev?.startTime + startTime: Number.isFinite(statusInfo.startTime) + ? statusInfo.startTime + : prev?.startTime, })); - + setIsLoading(true); setCanAbortSession(statusInfo.can_interrupt); break; @@ -1407,9 +2464,30 @@ export function useChatRealtimeHandlers({ break; } }, [ - latestMessage, provider, selectedProject, selectedSession, currentSessionId, setCurrentSessionId, - setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setStatusTextOverride, setTokenBudget, - setIsSystemSessionChange, setPendingPermissionRequests, onSessionInactive, onSessionProcessing, - onSessionNotProcessing, onSessionStatusResolved, onReplaceTemporarySession, onNavigateToSession, + latestMessage, + provider, + selectedProject, + selectedSession, + currentSessionId, + setCurrentSessionId, + setChatMessages, + setIsLoading, + setCanAbortSession, + setClaudeStatus, + setStatusTextOverride, + setTokenBudget, + setIsSystemSessionChange, + setPendingPermissionRequests, + onSessionInactive, + onSessionProcessing, + onSessionNotProcessing, + onSessionStatusResolved, + onCodexTurnStarted, + onCodexTurnSettled, + onCodexSessionBusy, + onCodexSessionIdResolved, + onReplaceTemporarySession, + onNavigateToSession, + sendMessage, ]); } diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 0f26e87b..6bb1f4b3 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -5,23 +5,46 @@ import { api, authenticatedFetch } from '../../../utils/api'; import { RESUMING_STATUS_TEXT } from '../types/types'; import type { ChatMessage, Provider, TokenBudget } from '../types/types'; import type { Project, ProjectSession } from '../../../types/app'; -import { clearSessionTimerStart, readSessionTimerStart, safeLocalStorage } from '../utils/chatStorage'; +import { + buildChatMessagesStorageKey, + clearScopedProviderSessionId, + persistScopedProviderSessionId, + clearSessionTimerStart, + readSessionTimerStart, + safeLocalStorage, +} from '../utils/chatStorage'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; import { convertCursorSessionMessages, convertSessionMessages, createCachedDiffCalculator, type DiffCalculator, } from '../utils/messageTransforms'; +import { + resolveSessionLoadProvider, + shouldSkipSessionMessageLoad, +} from '../utils/sessionLoadGuards'; +import { buildSessionMessageCacheCandidateKeys } from '../utils/sessionMessageCache'; +import { + buildSessionSnapshotKey, + cloneSessionSnapshot, + createSessionSnapshot, + type SessionSnapshot, +} from '../utils/sessionSnapshotCache'; +import { + buildSessionScopeKey, + parseSessionScopeKey, + scopeKeyMatchesSessionId, +} from '../../../utils/sessionScope'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; /** Grace period for WebSocket status-check response before clearing stale resume state */ const STATUS_VALIDATION_TIMEOUT_MS = 5000; - +const MAX_SESSION_SNAPSHOT_CACHE_ENTRIES = 40; /** - * Prefer session.__provider; else infer from project session lists. - * Never fall back to chat composer `selected-provider` — if that is "nano", every unmatched session - * would request provider=nano and load empty history. + * Infer provider from project session lists when session metadata is incomplete. + * This is a final fallback only after session-bound and UI provider hints are considered. */ function resolveSessionProviderForLoad(session: ProjectSession | null, project: Project | null): Provider | string { if (session?.__provider) { @@ -41,6 +64,50 @@ function resolveSessionProviderForLoad(session: ProjectSession | null, project: return 'claude'; } +function readStoredChatMessages( + projectName: string, + sessionId: string, + provider: Provider | string | null | undefined, +): ChatMessage[] { + const candidateKeys = buildSessionMessageCacheCandidateKeys( + projectName, + sessionId, + provider, + ); + + for (const key of candidateKeys) { + const raw = safeLocalStorage.getItem(key); + if (!raw) { + continue; + } + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed as ChatMessage[]; + } + } catch { + console.error(`Failed to parse saved chat messages for key: ${key}`); + safeLocalStorage.removeItem(key); + } + } + + return []; +} + +function hasSessionHistoryHint(session: ProjectSession | null | undefined): boolean { + if (!session) { + return false; + } + + const rawMessageCount = session.messageCount; + if (typeof rawMessageCount === 'number') { + return rawMessageCount > 0; + } + + const parsedMessageCount = Number(rawMessageCount); + return Number.isFinite(parsedMessageCount) && parsedMessageCount > 0; +} + type PendingViewSession = { sessionId: string | null; startedAt: number; @@ -49,6 +116,7 @@ type PendingViewSession = { interface UseChatSessionStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; + activeProvider?: Provider | null; ws: WebSocket | null; sendMessage: (message: unknown) => void; autoScrollToBottom?: boolean; @@ -63,9 +131,40 @@ interface ScrollRestoreState { top: number; } +const MESSAGE_ID_PREVIEW_LIMIT = 120; + +function toStablePreview(value: unknown, maxLength = MESSAGE_ID_PREVIEW_LIMIT): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value.slice(0, maxLength); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + return JSON.stringify(value).slice(0, maxLength); + } catch { + return String(value).slice(0, maxLength); + } +} + +function buildFallbackMessageFingerprint(message: ChatMessage): string { + const timestampValue = new Date(message.timestamp).getTime(); + const normalizedTimestamp = Number.isFinite(timestampValue) + ? String(timestampValue) + : toStablePreview(message.timestamp, 40); + + return [ + message.type || '', + normalizedTimestamp, + toStablePreview(message.content), + toStablePreview(message.reasoning), + toStablePreview(message.toolName, 80), + toStablePreview(message.toolInput), + message.isToolUse ? 'tool' : 'plain', + ].join('|'); +} + export function useChatSessionState({ selectedProject, selectedSession, + activeProvider, ws, sendMessage, autoScrollToBottom, @@ -77,30 +176,42 @@ export function useChatSessionState({ const persistedInitialStartTime = selectedSession?.id ? readSessionTimerStart(selectedSession.id) : null; const [chatMessages, _setChatMessages] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); - if (saved) { - try { - return JSON.parse(saved) as ChatMessage[]; - } catch { - console.error('Failed to parse saved chat messages, resetting'); - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - return []; - } - } - return []; + if (typeof window !== 'undefined' && selectedProject && selectedSession?.id) { + const inferredProvider = selectedSession.__provider + || (activeProvider as Provider | undefined) + || (window.localStorage.getItem('selected-provider') as Provider | null) + || resolveSessionProviderForLoad(selectedSession, selectedProject); + return readStoredChatMessages( + selectedProject.name, + selectedSession.id, + normalizeProvider(inferredProvider || DEFAULT_PROVIDER), + ); } return []; }); + const generatedMessageIdMapRef = useRef>(new Map()); + const setChatMessages = useCallback((updater: React.SetStateAction) => { _setChatMessages((prev) => { const next = typeof updater === 'function' ? updater(prev) : updater; let hasChanges = false; + const occurrenceByFingerprint = new Map(); const final = next.map((msg) => { if (!msg.id && !msg.messageId && !msg.toolId && !msg.toolCallId && !msg.blobId && !msg.rowid && !msg.sequence) { + const fingerprint = buildFallbackMessageFingerprint(msg); + const occurrence = (occurrenceByFingerprint.get(fingerprint) || 0) + 1; + occurrenceByFingerprint.set(fingerprint, occurrence); + const cacheKey = `${fingerprint}#${occurrence}`; + const existingId = generatedMessageIdMapRef.current.get(cacheKey); + const nextId = existingId || ((typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID() + : Math.random().toString(36).substring(2, 15)); + if (!existingId) { + generatedMessageIdMapRef.current.set(cacheKey, nextId); + } hasChanges = true; - return { ...msg, messageId: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15) }; + return { ...msg, messageId: nextId }; } return msg; }); @@ -108,9 +219,86 @@ export function useChatSessionState({ }); }, []); + const hasProcessingSession = useCallback( + ( + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, + projectName: string | null | undefined = selectedProject?.name || null, + ) => { + if (!processingSessions || !sessionId || !projectName) { + return false; + } + + const scopeKey = buildSessionScopeKey(projectName, provider || DEFAULT_PROVIDER, sessionId); + if (scopeKey && processingSessions.has(scopeKey)) { + return true; + } + + if (processingSessions.has(sessionId)) { + return true; + } + + for (const trackingKey of processingSessions) { + if (scopeKeyMatchesSessionId(trackingKey, sessionId)) { + const parsed = parseSessionScopeKey(trackingKey); + if (!parsed) { + continue; + } + if (parsed.projectName === projectName) { + const normalizedProvider = normalizeProvider(provider || DEFAULT_PROVIDER); + if (parsed.provider === normalizedProvider) { + return true; + } + } + } + } + + return false; + }, + [processingSessions, selectedProject?.name], + ); + + const resolvePreferredLoadProvider = useCallback( + ( + session: ProjectSession | null, + project: Project | null, + ): Provider => { + if (session?.__provider) { + return resolveSessionLoadProvider(session.__provider); + } + + if (activeProvider) { + return resolveSessionLoadProvider(activeProvider); + } + + if (typeof window !== 'undefined') { + const persistedProvider = window.localStorage.getItem('selected-provider'); + if (persistedProvider) { + return resolveSessionLoadProvider(persistedProvider as Provider); + } + } + + const inferredProvider = resolveSessionProviderForLoad(session, project); + if (inferredProvider) { + return resolveSessionLoadProvider(inferredProvider); + } + + return resolveSessionLoadProvider(DEFAULT_PROVIDER); + }, + [activeProvider], + ); + const [isLoading, setIsLoading] = useState(() => { - if (selectedSession?.id && processingSessions?.has(selectedSession.id)) { - return true; + if (selectedSession?.id && selectedProject?.name) { + const initialProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); + const scopeKey = buildSessionScopeKey( + selectedProject.name, + initialProvider, + selectedSession.id, + ); + if (scopeKey && processingSessions?.has(scopeKey)) { + return true; + } } if (persistedInitialStartTime) { return true; @@ -119,6 +307,7 @@ export function useChatSessionState({ }); const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); const [sessionMessages, setSessionMessages] = useState([]); + const [isSessionMessagesAuthoritative, setIsSessionMessagesAuthoritative] = useState(false); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false); @@ -150,22 +339,86 @@ export function useChatSessionState({ const scrollContainerRef = useRef(null); const isLoadingSessionRef = useRef(false); const isLoadingMoreRef = useRef(false); + const initialLoadCountRef = useRef(0); + const moreLoadCountRef = useRef(0); + const latestSelectionRef = useRef<{ projectName: string | null; sessionId: string | null }>({ + projectName: selectedProject?.name || null, + sessionId: selectedSession?.id || null, + }); + const sessionLoadGenerationRef = useRef(0); + const externalReloadGenerationRef = useRef(0); const allMessagesLoadedRef = useRef(false); const topLoadLockRef = useRef(false); const pendingScrollRestoreRef = useRef(null); const pendingInitialScrollRef = useRef(true); const messagesOffsetRef = useRef(0); + const lastScrollTopRef = useRef(0); const scrollPositionRef = useRef({ height: 0, top: 0 }); const loadAllFinishedTimerRef = useRef | null>(null); const loadAllOverlayTimerRef = useRef | null>(null); + const sessionSnapshotCacheRef = useRef>(new Map()); const createDiff = useMemo(() => createCachedDiffCalculator(), []); + const rememberSessionSnapshot = useCallback( + ( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, + nextSessionMessages: unknown[] | null | undefined, + nextChatMessages: ChatMessage[] | null | undefined, + ) => { + const cacheKey = buildSessionSnapshotKey(projectName, sessionId, provider); + if (!cacheKey) { + return; + } + + const cache = sessionSnapshotCacheRef.current; + if (cache.has(cacheKey)) { + cache.delete(cacheKey); + } + + cache.set(cacheKey, createSessionSnapshot(provider, nextSessionMessages, nextChatMessages)); + + if (cache.size > MAX_SESSION_SNAPSHOT_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey) { + cache.delete(oldestKey); + } + } + }, + [], + ); + + const readSessionSnapshot = useCallback( + ( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, + ): SessionSnapshot | null => { + const cacheKey = buildSessionSnapshotKey(projectName, sessionId, provider); + if (!cacheKey) { + return null; + } + + const snapshot = sessionSnapshotCacheRef.current.get(cacheKey); + return snapshot ? cloneSessionSnapshot(snapshot) : null; + }, + [], + ); + const pendingStatusValidationSessionIdRef = useRef(pendingStatusValidationSessionId); useEffect(() => { pendingStatusValidationSessionIdRef.current = pendingStatusValidationSessionId; }, [pendingStatusValidationSessionId]); + useEffect(() => { + latestSelectionRef.current = { + projectName: selectedProject?.name || null, + sessionId: selectedSession?.id || null, + }; + }, [selectedProject?.name, selectedSession?.id]); + const markSessionStatusCheckPending = useCallback((sessionId?: string | null) => { if (!sessionId) { return; @@ -183,15 +436,26 @@ export function useChatSessionState({ }, []); const loadSessionMessages = useCallback( - async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => { + async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = DEFAULT_PROVIDER) => { if (!projectName || !sessionId) { return [] as any[]; } + if (shouldSkipSessionMessageLoad(sessionId)) { + if (!loadMore) { + messagesOffsetRef.current = 0; + setHasMoreMessages(false); + setTotalMessages(0); + } + return [] as any[]; + } + const isInitialLoad = !loadMore; if (isInitialLoad) { + initialLoadCountRef.current += 1; setIsLoadingSessionMessages(true); } else { + moreLoadCountRef.current += 1; setIsLoadingMoreMessages(true); } @@ -209,7 +473,6 @@ export function useChatSessionState({ } const data = await response.json(); - console.log('[DEBUG] Received session messages data:', data); if (isInitialLoad && data.tokenUsage) { setTokenBudget(data.tokenUsage); } @@ -232,9 +495,11 @@ export function useChatSessionState({ return []; } finally { if (isInitialLoad) { - setIsLoadingSessionMessages(false); + initialLoadCountRef.current = Math.max(0, initialLoadCountRef.current - 1); + setIsLoadingSessionMessages(initialLoadCountRef.current > 0); } else { - setIsLoadingMoreMessages(false); + moreLoadCountRef.current = Math.max(0, moreLoadCountRef.current - 1); + setIsLoadingMoreMessages(moreLoadCountRef.current > 0); } } }, @@ -246,6 +511,7 @@ export function useChatSessionState({ return [] as ChatMessage[]; } + initialLoadCountRef.current += 1; setIsLoadingSessionMessages(true); try { const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`; @@ -261,7 +527,8 @@ export function useChatSessionState({ console.error('Error loading Cursor session messages:', error); return []; } finally { - setIsLoadingSessionMessages(false); + initialLoadCountRef.current = Math.max(0, initialLoadCountRef.current - 1); + setIsLoadingSessionMessages(initialLoadCountRef.current > 0); } }, []); @@ -305,7 +572,7 @@ export function useChatSessionState({ return false; } - const sessionProvider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider | string; + const sessionProvider = normalizeProvider(selectedSession.__provider || DEFAULT_PROVIDER); if (sessionProvider === 'cursor') { return false; } @@ -330,7 +597,17 @@ export function useChatSessionState({ height: previousScrollHeight, top: previousScrollTop, }; - setSessionMessages((previous) => [...moreMessages, ...previous]); + setSessionMessages((previous) => { + const nextMessages = [...moreMessages, ...previous]; + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + sessionProvider, + nextMessages, + [], + ); + return nextMessages; + }); // Keep the rendered window in sync with top-pagination so newly loaded history becomes visible. setVisibleMessageCount((previousCount) => previousCount + moreMessages.length); return true; @@ -338,7 +615,7 @@ export function useChatSessionState({ isLoadingMoreRef.current = false; } }, - [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession], + [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, rememberSessionSnapshot, selectedProject, selectedSession], ); const handleScroll = useCallback(async () => { @@ -347,18 +624,27 @@ export function useChatSessionState({ return; } + const currentScrollTop = container.scrollTop; + const wasScrollingUp = currentScrollTop <= lastScrollTopRef.current; + lastScrollTopRef.current = currentScrollTop; + const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); if (!allMessagesLoadedRef.current) { - const scrolledNearTop = container.scrollTop < 100; + if (!wasScrollingUp) { + topLoadLockRef.current = false; + return; + } + + const scrolledNearTop = currentScrollTop < 100; if (!scrolledNearTop) { topLoadLockRef.current = false; return; } if (topLoadLockRef.current) { - if (container.scrollTop > 20) { + if (currentScrollTop > 20) { topLoadLockRef.current = false; } return; @@ -382,14 +668,21 @@ export function useChatSessionState({ const scrollDiff = newScrollHeight - height; container.scrollTop = top + Math.max(scrollDiff, 0); pendingScrollRestoreRef.current = null; - }, [chatMessages.length]); + }, [chatMessages.length, sessionMessages.length]); useEffect(() => { pendingInitialScrollRef.current = true; topLoadLockRef.current = false; pendingScrollRestoreRef.current = null; + lastScrollTopRef.current = 0; + initialLoadCountRef.current = 0; + moreLoadCountRef.current = 0; + generatedMessageIdMapRef.current.clear(); setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); setIsUserScrolledUp(false); + setIsSessionMessagesAuthoritative(false); + setIsLoadingSessionMessages(false); + setIsLoadingMoreMessages(false); }, [selectedProject?.name, selectedSession?.id]); useEffect(() => { @@ -409,18 +702,55 @@ export function useChatSessionState({ }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); useEffect(() => { + let cancelled = false; + const requestGeneration = sessionLoadGenerationRef.current + 1; + sessionLoadGenerationRef.current = requestGeneration; + const requestSelection = { + projectName: selectedProject?.name || null, + sessionId: selectedSession?.id || null, + }; + const isStaleRequest = () => + cancelled + || sessionLoadGenerationRef.current !== requestGeneration + || latestSelectionRef.current.projectName !== requestSelection.projectName + || latestSelectionRef.current.sessionId !== requestSelection.sessionId; + const loadMessages = async () => { if (selectedSession && selectedProject) { - const currentProvider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider | string; + const currentProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); isLoadingSessionRef.current = true; + const cachedSnapshot = + !isSystemSessionChange + ? readSessionSnapshot(selectedProject.name, selectedSession.id, currentProvider) + : null; + const cachedStoredMessages = + !isSystemSessionChange + ? readStoredChatMessages(selectedProject.name, selectedSession.id, currentProvider) + : []; const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; if (sessionChanged) { if (!isSystemSessionChange) { resetStreamingState(); pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); + if (cachedSnapshot) { + if (currentProvider === 'cursor') { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages(cachedSnapshot.chatMessages); + } else { + setSessionMessages(cachedSnapshot.sessionMessages); + setIsSessionMessagesAuthoritative(true); + } + } else if (cachedStoredMessages.length > 0) { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages(cachedStoredMessages); + } else { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages([]); + } setClaudeStatus(null); setCanAbortSession(false); } @@ -440,13 +770,17 @@ export function useChatSessionState({ // Only set isLoading to false if it's NOT in the processingSessions set const isProcessing = - processingSessions?.has(selectedSession.id) || + hasProcessingSession(selectedSession.id, currentProvider, selectedProject.name) || pendingStatusValidationSessionIdRef.current === selectedSession.id; if (!isProcessing) { setIsLoading(false); } } + if (isStaleRequest()) { + return; + } + // Always check status if we have a websocket and a session, // especially on initial load or reconnect. if (ws && selectedSession?.id) { @@ -460,13 +794,31 @@ export function useChatSessionState({ if (currentProvider === 'cursor') { setCurrentSessionId(selectedSession.id); - sessionStorage.setItem('cursorSessionId', selectedSession.id); + persistScopedProviderSessionId(selectedProject.name, 'cursor', selectedSession.id); if (!isSystemSessionChange) { const projectPath = selectedProject.fullPath || selectedProject.path || ''; const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); + if (isStaleRequest()) { + return; + } + const shouldKeepCachedCursorMessages = + converted.length === 0 + && cachedStoredMessages.length > 0 + && hasSessionHistoryHint(selectedSession); + const nextCursorMessages = shouldKeepCachedCursorMessages + ? cachedStoredMessages + : converted; setSessionMessages([]); - setChatMessages(converted); + setIsSessionMessagesAuthoritative(false); + setChatMessages(nextCursorMessages); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + currentProvider, + [], + nextCursorMessages, + ); } else { setIsSystemSessionChange(false); } @@ -480,44 +832,126 @@ export function useChatSessionState({ false, currentProvider, ); - setSessionMessages(messages); + if (isStaleRequest()) { + return; + } + const shouldKeepCachedHistory = + messages.length === 0 + && cachedStoredMessages.length > 0 + && hasSessionHistoryHint(selectedSession); + + if (shouldKeepCachedHistory) { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages(cachedStoredMessages); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + currentProvider, + [], + cachedStoredMessages, + ); + } else { + setSessionMessages(messages); + setIsSessionMessagesAuthoritative(true); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + currentProvider, + messages, + [], + ); + } } else { setIsSystemSessionChange(false); } } } else { + const pendingViewSessionId = + pendingViewSessionRef.current?.sessionId || null; + const hasPendingOptimisticSession = + Boolean(pendingViewSessionRef.current) || + Boolean(currentSessionId && currentSessionId.startsWith("new-session-")); + const pendingOptimisticSessionId = + pendingViewSessionId || currentSessionId || null; + const hasPendingProcessing = + pendingOptimisticSessionId + ? hasProcessingSession( + pendingOptimisticSessionId, + selectedSession?.__provider || DEFAULT_PROVIDER, + selectedProject?.name || null, + ) + : Boolean( + processingSessions && + Array.from(processingSessions).some((sessionKey) => + sessionKey.startsWith('new-session-') || sessionKey.includes('::new-session-'), + ), + ); + const hasPendingStartTime = Boolean( + pendingOptimisticSessionId && + readSessionTimerStart(pendingOptimisticSessionId), + ); + const shouldKeepPendingLoading = + hasPendingOptimisticSession && + (hasPendingProcessing || hasPendingStartTime); + if (!isSystemSessionChange) { - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); + if (hasPendingOptimisticSession) { + setCanAbortSession(shouldKeepPendingLoading); + if (shouldKeepPendingLoading) { + setIsLoading(true); + } + } else { + resetStreamingState(); + pendingViewSessionRef.current = null; + setChatMessages([]); + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setClaudeStatus(null); + setCanAbortSession(false); + setIsLoading(false); + } } - setCurrentSessionId(null); - sessionStorage.removeItem('cursorSessionId'); - messagesOffsetRef.current = 0; - setHasMoreMessages(false); - setTotalMessages(0); - setTokenBudget(null); + if (hasPendingOptimisticSession) { + if (!currentSessionId && pendingViewSessionId) { + setCurrentSessionId(pendingViewSessionId); + } + } else { + setCurrentSessionId(null); + clearScopedProviderSessionId(selectedProject?.name || null, 'cursor'); + messagesOffsetRef.current = 0; + setHasMoreMessages(false); + setTotalMessages(0); + setTokenBudget(null); + } } setTimeout(() => { + if (isStaleRequest()) { + return; + } isLoadingSessionRef.current = false; }, 250); }; loadMessages(); + return () => { + cancelled = true; + }; }, [ // Intentionally exclude currentSessionId: this effect sets it and should not retrigger another full load. isSystemSessionChange, loadCursorSessionMessages, loadSessionMessages, + readSessionSnapshot, pendingViewSessionRef, + rememberSessionSnapshot, resetStreamingState, + resolvePreferredLoadProvider, markSessionStatusCheckPending, + hasProcessingSession, + processingSessions, selectedProject, selectedSession, sendMessage, @@ -529,15 +963,51 @@ export function useChatSessionState({ return; } + let cancelled = false; + const requestGeneration = externalReloadGenerationRef.current + 1; + externalReloadGenerationRef.current = requestGeneration; + const reloadSelection = { + projectName: selectedProject.name, + sessionId: selectedSession.id, + }; + const isStaleReload = () => + cancelled + || externalReloadGenerationRef.current !== requestGeneration + || latestSelectionRef.current.projectName !== reloadSelection.projectName + || latestSelectionRef.current.sessionId !== reloadSelection.sessionId; + const reloadExternalMessages = async () => { try { - const provider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider; + const provider = resolvePreferredLoadProvider(selectedSession, selectedProject); + const cachedStoredMessages = readStoredChatMessages( + selectedProject.name, + selectedSession.id, + provider, + ); if (provider === 'cursor') { const projectPath = selectedProject.fullPath || selectedProject.path || ''; const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); + if (isStaleReload()) { + return; + } + const shouldKeepCachedCursorMessages = + converted.length === 0 + && cachedStoredMessages.length > 0 + && hasSessionHistoryHint(selectedSession); + const nextCursorMessages = shouldKeepCachedCursorMessages + ? cachedStoredMessages + : converted; setSessionMessages([]); - setChatMessages(converted); + setIsSessionMessagesAuthoritative(false); + setChatMessages(nextCursorMessages); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + provider, + [], + nextCursorMessages, + ); return; } @@ -547,7 +1017,36 @@ export function useChatSessionState({ false, provider, ); - setSessionMessages(messages); + if (isStaleReload()) { + return; + } + const shouldKeepCachedHistory = + messages.length === 0 + && cachedStoredMessages.length > 0 + && hasSessionHistoryHint(selectedSession); + + if (shouldKeepCachedHistory) { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages(cachedStoredMessages); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + provider, + [], + cachedStoredMessages, + ); + } else { + setSessionMessages(messages); + setIsSessionMessagesAuthoritative(true); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + provider, + messages, + [], + ); + } const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom(); if (shouldAutoScroll) { @@ -559,12 +1058,17 @@ export function useChatSessionState({ }; reloadExternalMessages(); + return () => { + cancelled = true; + }; }, [ autoScrollToBottom, externalMessageUpdate, isNearBottom, loadCursorSessionMessages, loadSessionMessages, + rememberSessionSnapshot, + resolvePreferredLoadProvider, scrollToBottom, selectedProject, selectedSession, @@ -577,16 +1081,50 @@ export function useChatSessionState({ }, [pendingViewSessionRef, selectedSession?.id]); useEffect(() => { - // Sync converted messages to chat state. - // We update even for empty arrays to clear old state when switching to an empty session. + // Only sync converted session payloads when sessionMessages are the authoritative source. + // Cursor and compatibility fallbacks write directly to chatMessages. + if (!isSessionMessagesAuthoritative) { + return; + } setChatMessages(convertedMessages); - }, [convertedMessages, setChatMessages]); + }, [convertedMessages, isSessionMessagesAuthoritative, setChatMessages]); useEffect(() => { - if (selectedProject && chatMessages.length > 0) { - safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages)); + const activeSessionId = selectedSession?.id || currentSessionId; + const resolvedActiveProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); + const storageKey = buildChatMessagesStorageKey( + selectedProject?.name || null, + activeSessionId, + resolvedActiveProvider, + ); + + if (!storageKey) { + return; + } + + if (chatMessages.length > 0) { + safeLocalStorage.setItem(storageKey, JSON.stringify(chatMessages)); + return; + } + + if (isLoadingSessionMessages || isLoading || !isSessionMessagesAuthoritative) { + return; } - }, [chatMessages, selectedProject]); + + safeLocalStorage.removeItem(storageKey); + }, [ + chatMessages, + currentSessionId, + isLoading, + isLoadingSessionMessages, + isSessionMessagesAuthoritative, + resolvePreferredLoadProvider, + selectedProject, + selectedSession, + selectedProject?.name, + selectedSession?.id, + selectedSession?.__provider, + ]); useEffect(() => { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { @@ -594,7 +1132,7 @@ export function useChatSessionState({ return; } - const sessionProvider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider | string; + const sessionProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); if (sessionProvider === 'cursor') { setTokenBudget(null); return; @@ -616,7 +1154,7 @@ export function useChatSessionState({ }; fetchInitialTokenUsage(); - }, [selectedProject, selectedSession]); + }, [resolvePreferredLoadProvider, selectedProject, selectedSession]); const visibleMessages = useMemo(() => { if (chatMessages.length <= visibleMessageCount) { @@ -694,7 +1232,12 @@ export function useChatSessionState({ }); } - const isTrackedProcessing = Boolean(processingSessions?.has(activeViewSessionId)); + const activeProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); + const isTrackedProcessing = hasProcessingSession( + activeViewSessionId, + activeProvider, + selectedProject?.name || null, + ); const isAwaitingStatusValidation = pendingStatusValidationSessionId === activeViewSessionId && Boolean(persistedStartTime); const shouldBeProcessing = isTrackedProcessing || isAwaitingStatusValidation; @@ -703,7 +1246,17 @@ export function useChatSessionState({ setIsLoading(true); setCanAbortSession(true); } - }, [currentSessionId, isLoading, pendingStatusValidationSessionId, processingSessions, selectedSession?.id]); + }, [ + currentSessionId, + hasProcessingSession, + isLoading, + pendingStatusValidationSessionId, + resolvePreferredLoadProvider, + selectedProject, + selectedProject?.name, + selectedSession?.id, + selectedSession?.__provider, + ]); useEffect(() => { const activeViewSessionId = selectedSession?.id || currentSessionId; @@ -712,12 +1265,25 @@ export function useChatSessionState({ } const persistedStartTime = readSessionTimerStart(activeViewSessionId); - if (!persistedStartTime || processingSessions?.has(activeViewSessionId)) { + if ( + !persistedStartTime || + hasProcessingSession( + activeViewSessionId, + resolvePreferredLoadProvider(selectedSession, selectedProject), + selectedProject?.name || null, + ) + ) { return; } const timeoutId = window.setTimeout(() => { - if (processingSessions?.has(activeViewSessionId)) { + if ( + hasProcessingSession( + activeViewSessionId, + resolvePreferredLoadProvider(selectedSession, selectedProject), + selectedProject?.name || null, + ) + ) { return; } @@ -736,7 +1302,16 @@ export function useChatSessionState({ return () => { clearTimeout(timeoutId); }; - }, [currentSessionId, pendingStatusValidationSessionId, processingSessions, selectedSession?.id]); + }, [ + currentSessionId, + hasProcessingSession, + pendingStatusValidationSessionId, + resolvePreferredLoadProvider, + selectedProject, + selectedProject?.name, + selectedSession?.id, + selectedSession?.__provider, + ]); // Show "Load all" overlay after a batch finishes loading, persist for 2s then hide const prevLoadingRef = useRef(false); @@ -763,7 +1338,7 @@ export function useChatSessionState({ const loadAllMessages = useCallback(async () => { if (!selectedSession || !selectedProject) return; if (isLoadingAllMessages) return; - const sessionProvider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider | string; + const sessionProvider = normalizeProvider(selectedSession.__provider || DEFAULT_PROVIDER); if (sessionProvider === 'cursor') { setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); @@ -814,6 +1389,13 @@ export function useChatSessionState({ setHasMoreMessages(false); setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0); messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0; + rememberSessionSnapshot( + selectedProject.name, + requestSessionId, + sessionProvider, + Array.isArray(allMessages) ? allMessages : [], + [], + ); setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); @@ -836,7 +1418,7 @@ export function useChatSessionState({ isLoadingMoreRef.current = false; setIsLoadingAllMessages(false); } - }, [selectedSession, selectedProject, isLoadingAllMessages, currentSessionId]); + }, [currentSessionId, isLoadingAllMessages, rememberSessionSnapshot, selectedProject, selectedSession]); const loadEarlierMessages = useCallback(() => { setVisibleMessageCount((previousCount) => previousCount + 100); @@ -886,3 +1468,4 @@ export function useChatSessionState({ resolveSessionStatusCheck, }; } + diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 32a9eec9..c6c61071 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -129,6 +129,21 @@ export interface Question { multiSelect?: boolean; } +export type QueuedTurnKind = 'normal' | 'steer'; +export type QueuedTurnStatus = 'queued' | 'paused'; + +export interface QueuedTurn { + id: string; + sessionId: string; + text: string; + kind: QueuedTurnKind; + status: QueuedTurnStatus; + createdAt: number; + projectName?: string; + projectPath?: string; + sessionMode?: SessionMode; +} + export interface ChatInterfaceProps { selectedProject: Project | null; selectedSession: ProjectSession | null; @@ -137,12 +152,33 @@ export interface ChatInterfaceProps { latestMessage: any; onFileOpen?: (filePath: string, diffInfo?: any) => void; onInputFocusChange?: (focused: boolean) => void; - onSessionActive?: (sessionId?: string | null) => void; - onSessionInactive?: (sessionId?: string | null) => void; - onSessionProcessing?: (sessionId?: string | null) => void; - onSessionNotProcessing?: (sessionId?: string | null) => void; + onSessionActive?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionInactive?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionProcessing?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionNotProcessing?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; processingSessions?: Set; - onReplaceTemporarySession?: (sessionId?: string | null) => void; + onReplaceTemporarySession?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + previousSessionId?: string | null, + ) => void; onNavigateToSession?: ( targetSessionId: string, targetProvider?: SessionProvider, @@ -156,13 +192,14 @@ export interface ChatInterfaceProps { sendByCtrlEnter?: boolean; externalMessageUpdate?: number; onTaskClick?: (...args: unknown[]) => void; - onStartWorkspaceQa?: (project: Project, prompt: string) => void; + onShowAllTasks?: (() => void) | null; + onStartWorkspaceQa?: ((project: Project, prompt: string) => void) | null; pendingAutoIntake?: PendingAutoIntake | null; clearPendingAutoIntake?: () => void; importedProjectAnalysisPrompt?: ImportedProjectAnalysisPrompt | null; clearImportedProjectAnalysisPrompt?: () => void; - initialInputDraft?: string | null; onOpenShellForSession?: () => void; + initialInputDraft?: string | null; newSessionMode?: SessionMode; onNewSessionModeChange?: (mode: SessionMode) => void; } diff --git a/src/components/chat/utils/__tests__/codexQueue.test.ts b/src/components/chat/utils/__tests__/codexQueue.test.ts new file mode 100644 index 00000000..131f10c1 --- /dev/null +++ b/src/components/chat/utils/__tests__/codexQueue.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import { + buildQueuedTurn, + enqueueSessionTurn, + getNextDispatchableTurn, + promoteQueuedTurnToSteer, + reconcileSessionQueueId, + reconcileSettledSessionQueue, + type SessionQueueMap, +} from "../codexQueue"; + +describe("codexQueue", () => { + it("prepends steer turns when enqueueing", () => { + const sessionId = "session-1"; + const initialQueue: SessionQueueMap = { + [sessionId]: [ + buildQueuedTurn({ + id: "normal-1", + sessionId, + text: "normal one", + kind: "normal", + }), + ], + }; + + const next = enqueueSessionTurn( + initialQueue, + buildQueuedTurn({ + id: "steer-1", + sessionId, + text: "steer one", + kind: "steer", + }), + ); + + expect(next[sessionId].map((turn) => turn.id)).toEqual([ + "steer-1", + "normal-1", + ]); + }); + + it("chooses a queued steer turn before normal turns", () => { + const queue = [ + buildQueuedTurn({ + id: "normal-1", + sessionId: "session-1", + text: "normal one", + kind: "normal", + }), + buildQueuedTurn({ + id: "steer-1", + sessionId: "session-1", + text: "steer one", + kind: "steer", + }), + ]; + + const next = getNextDispatchableTurn(queue); + expect(next?.id).toBe("steer-1"); + }); + + it("promotes a queued turn to steer and moves it to the top", () => { + const sessionId = "session-1"; + const initialQueue: SessionQueueMap = { + [sessionId]: [ + buildQueuedTurn({ + id: "normal-1", + sessionId, + text: "normal one", + kind: "normal", + }), + buildQueuedTurn({ + id: "normal-2", + sessionId, + text: "normal two", + kind: "normal", + }), + ], + }; + + const next = promoteQueuedTurnToSteer( + initialQueue, + sessionId, + "normal-2", + ); + + expect(next[sessionId][0].id).toBe("normal-2"); + expect(next[sessionId][0].kind).toBe("steer"); + expect(next[sessionId][1].id).toBe("normal-1"); + }); + + it("reconciles temporary session queues into the settled session while preserving order", () => { + const tempSessionId = "new-session-123"; + const settledSessionId = "session-42"; + const initialQueue: SessionQueueMap = { + [settledSessionId]: [ + buildQueuedTurn({ + id: "existing-1", + sessionId: settledSessionId, + text: "existing queued turn", + kind: "normal", + }), + ], + [tempSessionId]: [ + buildQueuedTurn({ + id: "temp-1", + sessionId: tempSessionId, + text: "first temp turn", + kind: "normal", + }), + buildQueuedTurn({ + id: "temp-2", + sessionId: tempSessionId, + text: "second temp turn", + kind: "steer", + }), + ], + }; + + const reconciled = reconcileSessionQueueId( + initialQueue, + tempSessionId, + settledSessionId, + ); + + expect(reconciled[tempSessionId]).toBeUndefined(); + expect(reconciled[settledSessionId].map((turn) => turn.id)).toEqual([ + "existing-1", + "temp-1", + "temp-2", + ]); + expect(reconciled[settledSessionId].map((turn) => turn.sessionId)).toEqual([ + settledSessionId, + settledSessionId, + settledSessionId, + ]); + }); + + it("treats reconciliation as a no-op when the source queue is empty", () => { + const initialQueue: SessionQueueMap = { + "session-1": [ + buildQueuedTurn({ + id: "turn-1", + sessionId: "session-1", + text: "only turn", + kind: "normal", + }), + ], + }; + + const reconciled = reconcileSessionQueueId( + initialQueue, + "new-session-404", + "session-1", + ); + + expect(reconciled).toBe(initialQueue); + }); + + it("does not reconcile settled queues for non-temporary fallback ids", () => { + const queueBySession: SessionQueueMap = { + "session-real": [ + buildQueuedTurn({ + id: "real-1", + sessionId: "session-real", + text: "real turn", + kind: "normal", + }), + ], + "session-fallback": [ + buildQueuedTurn({ + id: "fallback-1", + sessionId: "session-fallback", + text: "fallback turn", + kind: "normal", + }), + ], + }; + + const reconciled = reconcileSettledSessionQueue( + queueBySession, + "session-real", + "session-fallback", + ); + + expect(reconciled).toBe(queueBySession); + }); +}); diff --git a/src/components/chat/utils/__tests__/sessionContextSummary.test.ts b/src/components/chat/utils/__tests__/sessionContextSummary.test.ts index 91770edc..ceb030f4 100644 --- a/src/components/chat/utils/__tests__/sessionContextSummary.test.ts +++ b/src/components/chat/utils/__tests__/sessionContextSummary.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - deriveSessionContextSummary, - mergeDistinctChatMessages, - resolveSessionContextProjectRoot, -} from '../sessionContextSummary'; +import { deriveSessionContextSummary, mergeDistinctChatMessages } from '../sessionContextSummary'; describe('deriveSessionContextSummary', () => { const projectRoot = '/workspace/demo'; @@ -106,225 +102,6 @@ describe('deriveSessionContextSummary', () => { expect(summary.outputFiles[0].relativePath).toBe('outputs/report.md'); expect(summary.outputFiles[0].unread).toBe(true); }); - - it('recognizes Codex shell reads, plans, patch outputs, and web actions', () => { - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-30T05:18:28.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: "sed -n '1,200p' src/components/chat/utils/sessionContextSummary.ts", - workdir: projectRoot, - }), - toolResult: { - content: 'const summary = true;', - isError: false, - }, - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:19:00.000Z', - isToolUse: true, - toolName: 'UpdatePlan', - toolInput: JSON.stringify({ - plan: [ - { step: 'Normalize Codex history', status: 'in_progress' }, - { step: 'Expand session summary', status: 'pending' }, - ], - }), - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:20:00.000Z', - isToolUse: true, - toolName: 'Edit', - toolInput: JSON.stringify({ - file_path: 'src/components/chat/utils/sessionContextSummary.ts', - file_paths: [ - 'src/components/chat/utils/sessionContextSummary.ts', - 'docs/plan.md', - ], - }), - toolResult: { - content: 'Success', - isError: false, - toolUseResult: { - changes: { - 'src/components/chat/utils/sessionContextSummary.ts': { type: 'update' }, - 'docs/plan.md': { type: 'add' }, - }, - }, - }, - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:21:00.000Z', - isToolUse: true, - toolName: 'WebSearch', - toolInput: JSON.stringify({ query: 'Codex session context panel' }), - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:21:30.000Z', - isToolUse: true, - toolName: 'OpenPage', - toolInput: JSON.stringify({ url: 'https://developers.openai.com/api/docs' }), - }, - { - type: 'assistant', - timestamp: '2026-03-30T05:22:00.000Z', - isToolUse: true, - toolName: 'FindInPage', - toolInput: JSON.stringify({ - url: 'https://developers.openai.com/api/docs', - pattern: 'session', - }), - }, - ] as any; - - const summary = deriveSessionContextSummary(messages, projectRoot); - - expect(summary.contextFiles.some((item) => item.relativePath === 'src/components/chat/utils/sessionContextSummary.ts')).toBe(true); - expect(summary.outputFiles.map((item) => item.relativePath).sort()).toEqual([ - 'docs/plan.md', - 'src/components/chat/utils/sessionContextSummary.ts', - ]); - expect(summary.tasks.some((item) => item.label === 'Normalize Codex history' && item.kind === 'todo')).toBe(true); - expect(summary.tasks.some((item) => item.label === 'Codex session context panel')).toBe(true); - expect(summary.tasks.some((item) => item.label === 'https://developers.openai.com/api/docs')).toBe(true); - expect(summary.tasks.some((item) => item.label === 'session')).toBe(true); - }); - - it('ignores dotted shell fragments that are not real file paths', () => { - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-31T12:00:00.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: "jq '.sections.survey.synthesis_summary, .sections.survey.open_gaps' pipeline/docs/research_brief.json", - workdir: projectRoot, - }), - toolResult: { - content: '', - isError: false, - }, - }, - ] as any; - - const summary = deriveSessionContextSummary(messages, projectRoot); - const contextPaths = summary.contextFiles.map((item) => item.relativePath); - - expect(contextPaths).toContain('pipeline/docs/research_brief.json'); - expect(contextPaths.some((path) => path.includes('sections.survey'))).toBe(false); - expect(contextPaths.some((path) => path.includes('open_gaps'))).toBe(false); - }); - - it('strips trailing punctuation from real paths and rejects slash-delimited prose fragments', () => { - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-31T12:05:00.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: 'cat ./pipeline/docs/research_brief.json. ./Survey/reports/model_dataset_inventory.json. 3.0/GPT-5.3.', - workdir: projectRoot, - }), - toolResult: { - content: 'Wrote ./pipeline/tasks/tasks.json.', - isError: false, - }, - }, - ] as any; - - const summary = deriveSessionContextSummary(messages, projectRoot); - const contextPaths = summary.contextFiles.map((item) => item.relativePath).sort(); - - expect(contextPaths).toContain('pipeline/docs/research_brief.json'); - expect(contextPaths).toContain('Survey/reports/model_dataset_inventory.json'); - expect(contextPaths).toContain('pipeline/tasks/tasks.json'); - expect(contextPaths.some((path) => path.endsWith('.'))).toBe(false); - expect(contextPaths).not.toContain('3.0/GPT-5.3.'); - expect(contextPaths).not.toContain('3.0/GPT-5.3'); - }); - - it('does not turn slash-delimited prose into shell directories', () => { - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-31T12:10:00.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: 'python analyze.py cost/latency GeneAgent/BioAgents/GeneGPT HealthBench/MedAgentBench ./Survey/reports', - workdir: projectRoot, - }), - toolResult: { - content: 'Compared GPT-5.3. with cost/latency tradeoffs.', - isError: false, - }, - }, - ] as any; - - const summary = deriveSessionContextSummary(messages, projectRoot); - const directoryLabels = summary.directories.map((item) => item.label); - const contextPaths = summary.contextFiles.map((item) => item.relativePath); - - expect(directoryLabels).toContain('Survey/reports'); - expect(directoryLabels).not.toContain('cost/latency'); - expect(directoryLabels).not.toContain('GeneAgent/BioAgents/GeneGPT'); - expect(directoryLabels).not.toContain('HealthBench/MedAgentBench'); - expect(contextPaths).not.toContain('cost/latency'); - expect(contextPaths).not.toContain('GeneAgent/BioAgents/GeneGPT'); - expect(contextPaths).not.toContain('HealthBench/MedAgentBench'); - expect(contextPaths).not.toContain('GPT-5.3.'); - expect(contextPaths).not.toContain('GPT-5.3'); - }); - - it('prefers session workdir over an unrelated project root when normalizing paths', () => { - const wrongProjectRoot = '/workspace/demo'; - const sessionRoot = '/home/testuser/projects/experiment-2026'; - const messages = [ - { - type: 'assistant', - timestamp: '2026-03-31T12:15:00.000Z', - isToolUse: true, - toolName: 'Bash', - toolInput: JSON.stringify({ - command: `cat ${sessionRoot}/instance.json ./instance.json`, - workdir: sessionRoot, - }), - toolResult: { - content: '', - isError: false, - }, - }, - { - type: 'assistant', - timestamp: '2026-03-31T12:16:00.000Z', - isToolUse: true, - toolName: 'LS', - toolInput: JSON.stringify({ - path: `${sessionRoot}/analysis`, - }), - }, - ] as any; - - expect(resolveSessionContextProjectRoot(messages, wrongProjectRoot)).toBe(sessionRoot); - - const summary = deriveSessionContextSummary(messages, wrongProjectRoot); - const contextPaths = summary.contextFiles.map((item) => item.relativePath); - const directoryLabels = summary.directories.map((item) => item.label); - - expect(contextPaths).toEqual(['instance.json']); - expect(directoryLabels).toEqual(['analysis']); - expect(contextPaths.some((path) => path.startsWith('/Users/'))).toBe(false); - expect(directoryLabels.some((label) => label.startsWith('/Users/'))).toBe(false); - }); }); describe('mergeDistinctChatMessages', () => { diff --git a/src/components/chat/utils/__tests__/sessionLoadGuards.test.ts b/src/components/chat/utils/__tests__/sessionLoadGuards.test.ts new file mode 100644 index 00000000..00a79724 --- /dev/null +++ b/src/components/chat/utils/__tests__/sessionLoadGuards.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveSessionLoadProvider, + shouldApplySessionLoadResult, + shouldSkipSessionMessageLoad, +} from '../sessionLoadGuards'; +import { DEFAULT_PROVIDER } from '../../../../utils/providerPolicy'; + +describe('session load guards', () => { + it('keeps the selected session provider when valid', () => { + expect(resolveSessionLoadProvider(DEFAULT_PROVIDER)).toBe(DEFAULT_PROVIDER); + }); + + it('falls back to default provider when provider is missing or invalid', () => { + expect(resolveSessionLoadProvider(undefined)).toBe(DEFAULT_PROVIDER); + expect(resolveSessionLoadProvider(null)).toBe(DEFAULT_PROVIDER); + expect(resolveSessionLoadProvider('unknown-provider')).toBe(DEFAULT_PROVIDER); + }); + + it('only applies load results for the active, non-cancelled request', () => { + expect(shouldApplySessionLoadResult(1, 1, false)).toBe(true); + expect(shouldApplySessionLoadResult(1, 2, false)).toBe(false); + expect(shouldApplySessionLoadResult(2, 2, true)).toBe(false); + }); + + it('skips history fetch for temporary session ids', () => { + expect(shouldSkipSessionMessageLoad('new-session-123')).toBe(true); + expect(shouldSkipSessionMessageLoad('temp-abc')).toBe(true); + expect(shouldSkipSessionMessageLoad('019d82e8-1ee3-7860-baa1-24603f424ade')).toBe(false); + expect(shouldSkipSessionMessageLoad('')).toBe(false); + expect(shouldSkipSessionMessageLoad(null)).toBe(false); + }); + + it('prevents stale request overwrite after a fast session switch', () => { + const requestA = 1; + const requestB = 2; + + // Request A started first, then B replaced it. + expect(shouldApplySessionLoadResult(requestA, requestB, false)).toBe(false); + expect(shouldApplySessionLoadResult(requestB, requestB, false)).toBe(true); + }); + + it('prevents older async loads from overwriting a newer session payload', async () => { + let activeRequestId = 0; + const appliedPayloads: string[] = []; + + const runLoad = (payload: string, delayMs: number) => { + activeRequestId += 1; + const requestId = activeRequestId; + + return new Promise((resolve) => { + setTimeout(() => { + if (shouldApplySessionLoadResult(requestId, activeRequestId, false)) { + appliedPayloads.push(payload); + } + resolve(); + }, delayMs); + }); + }; + + // A starts first but finishes later; B starts later but finishes first. + const loadA = runLoad('session-A', 40); + const loadB = runLoad('session-B', 5); + await Promise.all([loadA, loadB]); + + expect(appliedPayloads).toEqual(['session-B']); + }); +}); + diff --git a/src/components/chat/utils/__tests__/sessionMessageCache.test.ts b/src/components/chat/utils/__tests__/sessionMessageCache.test.ts new file mode 100644 index 00000000..3df1a153 --- /dev/null +++ b/src/components/chat/utils/__tests__/sessionMessageCache.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSessionMessageCacheCandidateKeys } from '../sessionMessageCache'; + +describe('sessionMessageCache', () => { + it('returns provider/session scoped keys plus legacy project+session key', () => { + const keys = buildSessionMessageCacheCandidateKeys('proj-a', 'sess-1', 'codex'); + + expect(keys).toEqual( + expect.arrayContaining([ + 'chat_messages_proj-a_codex_sess-1', + 'chat_messages_proj-a_claude_sess-1', + 'chat_messages_proj-a_sess-1', + ]), + ); + }); + + it('does not include project-only legacy key to avoid cross-session replay', () => { + const keys = buildSessionMessageCacheCandidateKeys('proj-a', 'sess-1', 'codex'); + + expect(keys).not.toContain('chat_messages_proj-a'); + }); + + it('deduplicates keys when provider already resolves to default', () => { + const keys = buildSessionMessageCacheCandidateKeys('proj-a', 'sess-1', 'claude'); + + const unique = new Set(keys); + expect(unique.size).toBe(keys.length); + }); +}); diff --git a/src/components/chat/utils/__tests__/sessionSnapshotCache.test.ts b/src/components/chat/utils/__tests__/sessionSnapshotCache.test.ts new file mode 100644 index 00000000..10abbf48 --- /dev/null +++ b/src/components/chat/utils/__tests__/sessionSnapshotCache.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildSessionSnapshotKey, + cloneSessionSnapshot, + createSessionSnapshot, + normalizeSessionSnapshotProvider, +} from '../sessionSnapshotCache'; +import { DEFAULT_PROVIDER } from '../../../../utils/providerPolicy'; + +describe('sessionSnapshotCache', () => { + it('normalizes provider and builds stable cache keys', () => { + expect(normalizeSessionSnapshotProvider(undefined)).toBe(DEFAULT_PROVIDER); + expect(normalizeSessionSnapshotProvider('unknown-provider')).toBe(DEFAULT_PROVIDER); + expect(buildSessionSnapshotKey('p1', 's1', undefined)).toBe(`p1::s1::${DEFAULT_PROVIDER}`); + expect(buildSessionSnapshotKey('', 's1', DEFAULT_PROVIDER)).toBe(''); + expect(buildSessionSnapshotKey('p1', '', DEFAULT_PROVIDER)).toBe(''); + }); + + it('creates snapshots without sharing source object references', () => { + const rawSessionMessages = [{ id: 1, text: 'hello' }]; + const rawChatMessages = [{ type: 'assistant', content: 'world', timestamp: new Date().toISOString() }] as any; + const snapshot = createSessionSnapshot(DEFAULT_PROVIDER, rawSessionMessages, rawChatMessages); + + rawSessionMessages[0].text = 'mutated'; + rawChatMessages[0].content = 'mutated'; + + expect((snapshot.sessionMessages[0] as any).text).toBe('hello'); + expect((snapshot.chatMessages[0] as any).content).toBe('world'); + }); + + it('clones stored snapshots so consumers cannot mutate cache by reference', () => { + const original = createSessionSnapshot( + DEFAULT_PROVIDER, + [{ id: 'session-message' }], + [{ type: 'assistant', content: 'cached', timestamp: new Date().toISOString() }] as any, + ); + const cloned = cloneSessionSnapshot(original); + + (cloned.sessionMessages[0] as any).id = 'mutated-session'; + (cloned.chatMessages[0] as any).content = 'mutated-chat'; + + expect((original.sessionMessages[0] as any).id).toBe('session-message'); + expect((original.chatMessages[0] as any).content).toBe('cached'); + }); +}); diff --git a/src/components/chat/utils/chatStorage.ts b/src/components/chat/utils/chatStorage.ts index f21c79bb..e9d8b7f5 100644 --- a/src/components/chat/utils/chatStorage.ts +++ b/src/components/chat/utils/chatStorage.ts @@ -1,4 +1,6 @@ import type { ProviderSettings } from '../types/types'; +import type { SessionProvider } from '../../../types/app'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; export const CLAUDE_SETTINGS_KEY = 'claude-settings'; export const GEMINI_SETTINGS_KEY = 'gemini-settings'; @@ -6,6 +8,10 @@ export const CURSOR_SETTINGS_KEY = 'cursor-tools-settings'; export const CODEX_SETTINGS_KEY = 'codex-settings'; export const NANO_SETTINGS_KEY = 'nano-claude-code-settings'; const SESSION_TIMER_PREFIX = 'session_timer_start_'; +const CHAT_MESSAGES_PREFIX = 'chat_messages_'; +const DRAFT_INPUT_PREFIX = 'draft_input_'; +const SCOPED_PENDING_SESSION_PREFIX = 'pending_session_id_'; +const SCOPED_PROVIDER_SESSION_PREFIX = 'provider_session_id_'; const safeSessionStorage = { setItem: (key: string, value: string) => { @@ -42,10 +48,56 @@ export function getProviderSettingsKey(provider?: string) { } } +function normalizeScopedStorageProvider( + provider?: SessionProvider | string | null, +): SessionProvider { + return normalizeProvider((provider || DEFAULT_PROVIDER) as SessionProvider); +} + +export function buildChatMessagesStorageKey( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider?: SessionProvider | string | null, +) { + if (!projectName || !sessionId) { + return ''; + } + + const normalizedProvider = normalizeScopedStorageProvider(provider); + return `${CHAT_MESSAGES_PREFIX}${projectName}_${normalizedProvider}_${sessionId}`; +} + +export function buildDraftInputStorageKey( + projectName: string | null | undefined, + provider?: SessionProvider | string | null, + sessionOrBucket: string | null | undefined = 'new', +) { + if (!projectName) { + return ''; + } + + const normalizedProvider = normalizeScopedStorageProvider(provider); + const normalizedBucket = sessionOrBucket || 'new'; + return `${DRAFT_INPUT_PREFIX}${projectName}_${normalizedProvider}_${normalizedBucket}`; +} + +function buildScopedSessionStorageKey( + prefix: string, + projectName: string | null | undefined, + provider?: SessionProvider | string | null, +) { + if (!projectName) { + return ''; + } + + const normalizedProvider = normalizeScopedStorageProvider(provider); + return `${prefix}${projectName}_${normalizedProvider}`; +} + export const safeLocalStorage = { setItem: (key: string, value: string) => { try { - if (key.startsWith('chat_messages_') && typeof value === 'string') { + if (key.startsWith(CHAT_MESSAGES_PREFIX) && typeof value === 'string') { try { const parsed = JSON.parse(value); if (Array.isArray(parsed) && parsed.length > 50) { @@ -80,7 +132,7 @@ export const safeLocalStorage = { localStorage.setItem(key, value); } catch (retryError) { console.error('Failed to save to localStorage even after cleanup:', retryError); - if (key.startsWith('chat_messages_') && typeof value === 'string') { + if (key.startsWith(CHAT_MESSAGES_PREFIX) && typeof value === 'string') { try { const parsed = JSON.parse(value); if (Array.isArray(parsed) && parsed.length > 10) { @@ -122,6 +174,80 @@ export function persistSessionTimerStart(sessionId: string | null | undefined, s safeSessionStorage.setItem(`${SESSION_TIMER_PREFIX}${sessionId}`, String(startTime)); } +export function persistScopedPendingSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, + sessionId: string | null | undefined, +) { + const storageKey = buildScopedSessionStorageKey(SCOPED_PENDING_SESSION_PREFIX, projectName, provider); + if (!storageKey || !sessionId) { + return; + } + + safeSessionStorage.setItem(storageKey, sessionId); +} + +export function readScopedPendingSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, +): string | null { + const storageKey = buildScopedSessionStorageKey(SCOPED_PENDING_SESSION_PREFIX, projectName, provider); + if (!storageKey) { + return null; + } + + return safeSessionStorage.getItem(storageKey); +} + +export function clearScopedPendingSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, +) { + const storageKey = buildScopedSessionStorageKey(SCOPED_PENDING_SESSION_PREFIX, projectName, provider); + if (!storageKey) { + return; + } + + safeSessionStorage.removeItem(storageKey); +} + +export function persistScopedProviderSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, + sessionId: string | null | undefined, +) { + const storageKey = buildScopedSessionStorageKey(SCOPED_PROVIDER_SESSION_PREFIX, projectName, provider); + if (!storageKey || !sessionId) { + return; + } + + safeSessionStorage.setItem(storageKey, sessionId); +} + +export function readScopedProviderSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, +): string | null { + const storageKey = buildScopedSessionStorageKey(SCOPED_PROVIDER_SESSION_PREFIX, projectName, provider); + if (!storageKey) { + return null; + } + + return safeSessionStorage.getItem(storageKey); +} + +export function clearScopedProviderSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, +) { + const storageKey = buildScopedSessionStorageKey(SCOPED_PROVIDER_SESSION_PREFIX, projectName, provider); + if (!storageKey) { + return; + } + + safeSessionStorage.removeItem(storageKey); +} + export function readSessionTimerStart(sessionId: string | null | undefined): number | null { if (!sessionId) { return null; diff --git a/src/components/chat/utils/codexQueue.ts b/src/components/chat/utils/codexQueue.ts new file mode 100644 index 00000000..6a35d6cc --- /dev/null +++ b/src/components/chat/utils/codexQueue.ts @@ -0,0 +1,171 @@ +import type { QueuedTurn, QueuedTurnKind, QueuedTurnStatus } from '../types/types'; + +export type SessionQueueMap = Record; + +type BuildQueuedTurnArgs = { + id: string; + sessionId: string; + text: string; + kind: QueuedTurnKind; + status?: QueuedTurnStatus; + createdAt?: number; + projectName?: string; + projectPath?: string; + sessionMode?: 'research' | 'workspace_qa'; +}; + +export function buildQueuedTurn({ + id, + sessionId, + text, + kind, + status = 'queued', + createdAt = Date.now(), + projectName, + projectPath, + sessionMode, +}: BuildQueuedTurnArgs): QueuedTurn { + return { + id, + sessionId, + text, + kind, + status, + createdAt, + projectName, + projectPath, + sessionMode, + }; +} + +export function getSessionQueue(queueBySession: SessionQueueMap, sessionId?: string | null): QueuedTurn[] { + if (!sessionId) { + return []; + } + return queueBySession[sessionId] || []; +} + +export function enqueueSessionTurn(queueBySession: SessionQueueMap, turn: QueuedTurn): SessionQueueMap { + const currentQueue = getSessionQueue(queueBySession, turn.sessionId); + if (turn.kind === 'steer') { + return { + ...queueBySession, + [turn.sessionId]: [turn, ...currentQueue], + }; + } + return { + ...queueBySession, + [turn.sessionId]: [...currentQueue, turn], + }; +} + +export function removeQueuedTurn( + queueBySession: SessionQueueMap, + sessionId: string, + turnId: string, +): SessionQueueMap { + const queue = getSessionQueue(queueBySession, sessionId); + const nextQueue = queue.filter((turn) => turn.id !== turnId); + if (nextQueue.length === 0) { + const { [sessionId]: _removed, ...rest } = queueBySession; + return rest; + } + return { + ...queueBySession, + [sessionId]: nextQueue, + }; +} + +export function setSessionQueueStatus( + queueBySession: SessionQueueMap, + sessionId: string, + status: QueuedTurnStatus, +): SessionQueueMap { + const queue = getSessionQueue(queueBySession, sessionId); + if (queue.length === 0) { + return queueBySession; + } + return { + ...queueBySession, + [sessionId]: queue.map((turn) => ({ ...turn, status })), + }; +} + +export function promoteQueuedTurnToSteer( + queueBySession: SessionQueueMap, + sessionId: string, + turnId: string, +): SessionQueueMap { + const queue = getSessionQueue(queueBySession, sessionId); + if (queue.length === 0) { + return queueBySession; + } + + const targetTurn = queue.find((turn) => turn.id === turnId); + if (!targetTurn) { + return queueBySession; + } + + const promotedTurn: QueuedTurn = { + ...targetTurn, + kind: 'steer', + }; + const remainingTurns = queue.filter((turn) => turn.id !== turnId); + + return { + ...queueBySession, + [sessionId]: [promotedTurn, ...remainingTurns], + }; +} + +export function getNextDispatchableTurn(queue: QueuedTurn[]): QueuedTurn | null { + const steerTurn = queue.find((turn) => turn.status === 'queued' && turn.kind === 'steer'); + if (steerTurn) { + return steerTurn; + } + return queue.find((turn) => turn.status === 'queued' && turn.kind === 'normal') || null; +} + +export function reconcileSessionQueueId( + queueBySession: SessionQueueMap, + fromSessionId?: string | null, + toSessionId?: string | null, +): SessionQueueMap { + if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) { + return queueBySession; + } + + const fromQueue = getSessionQueue(queueBySession, fromSessionId); + if (fromQueue.length === 0) { + return queueBySession; + } + + const toQueue = getSessionQueue(queueBySession, toSessionId); + const mergedQueue = [...toQueue, ...fromQueue.map((turn) => ({ ...turn, sessionId: toSessionId }))]; + + const { [fromSessionId]: _removed, ...rest } = queueBySession; + return { + ...rest, + [toSessionId]: mergedQueue, + }; +} + +export function reconcileSettledSessionQueue( + queueBySession: SessionQueueMap, + settledSessionId?: string | null, + fallbackTemporarySessionId?: string | null, +): SessionQueueMap { + if (!settledSessionId || !fallbackTemporarySessionId) { + return queueBySession; + } + + if (!fallbackTemporarySessionId.startsWith('new-session-')) { + return queueBySession; + } + + if (fallbackTemporarySessionId === settledSessionId) { + return queueBySession; + } + + return reconcileSessionQueueId(queueBySession, fallbackTemporarySessionId, settledSessionId); +} diff --git a/src/components/chat/utils/sessionContextSummary.ts b/src/components/chat/utils/sessionContextSummary.ts index 9d76f0ac..09793a48 100644 --- a/src/components/chat/utils/sessionContextSummary.ts +++ b/src/components/chat/utils/sessionContextSummary.ts @@ -20,8 +20,8 @@ export interface SessionContextTaskItem { key: string; label: string; detail?: string; - path?: string; kind: 'task' | 'todo' | 'skill' | 'directory'; + path?: string; count: number; lastSeenAt: string; } @@ -55,139 +55,21 @@ type TaskAccumulator = { key: string; label: string; detail?: string; - path?: string; kind: 'task' | 'todo' | 'skill' | 'directory'; + path?: string; count: number; lastSeenAt: string; }; const WINDOWS_ABS_PATTERN = /^[a-z]:\//i; -const MARKDOWN_FILE_LINK_PATTERN = /\]\((\/[^)\s]+)\)/g; -const ABSOLUTE_PATH_IN_TEXT_PATTERN = /(?:^|[\s("'`])((?:\/|[A-Za-z]:\/)[^)\s"'`]+)(?=$|[\s)"'`,:])/g; -const RELATIVE_PATH_IN_TEXT_PATTERN = /(?:^|[\s("'`])((?:\.\.?\/)?(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+)(?=$|[\s)"'`,:])/g; -const SHELL_TOKEN_PATTERN = /"[^"]*"|'[^']*'|`[^`]*`|\S+/g; -const SHELL_COMMAND_BREAKS = new Set(['|', '||', '&&', ';']); -const KNOWN_FILE_BASENAMES = new Set([ - '.env', - '.env.example', - '.gitignore', - '.npmrc', - '.prettierrc', - '.prettierrc.js', - '.prettierrc.json', - '.eslintrc', - '.eslintrc.js', - '.eslintrc.json', - 'Dockerfile', - 'Makefile', - 'README', - 'README.md', - 'README.zh-CN.md', - 'CHANGELOG.md', - 'package.json', - 'package-lock.json', - 'tsconfig.json', - 'vitest.config.ts', - 'vite.config.ts', - 'vite.config.js', - 'index.js', - 'index.ts', - 'index.tsx', - 'index.jsx', - 'AGENTS.md', - 'SKILL.md', - 'CLAUDE.md', -]); -const KNOWN_FILE_EXTENSIONS = new Set([ - 'c', - 'cc', - 'cpp', - 'css', - 'csv', - 'gif', - 'go', - 'h', - 'hpp', - 'html', - 'ini', - 'ipynb', - 'java', - 'jpeg', - 'jpg', - 'js', - 'json', - 'jsonl', - 'jsx', - 'kt', - 'less', - 'lock', - 'log', - 'lua', - 'md', - 'mdx', - 'mjs', - 'pdf', - 'php', - 'png', - 'py', - 'rb', - 'rs', - 'scss', - 'sh', - 'sql', - 'svg', - 'swift', - 'toml', - 'ts', - 'tsx', - 'txt', - 'tsv', - 'xml', - 'yaml', - 'yml', -]); const normalizePath = (value: string) => value.replace(/\\/g, '/').replace(/\/+/g, '/'); -/** Matches SKILL.md paths under .claude/, .agents/, .gemini/, or plain skills/ directories. */ -const SKILL_MD_PATH_RE = /\/(?:\.(?:claude|agents|gemini)\/)?skills\/([^/]+)\/SKILL\.md$/i; - -const trimTrailingPathPunctuation = (value: string) => { - let normalized = normalizePath(value).replace(/[),:;]+$/, ''); - - while (normalized.endsWith('.')) { - const candidate = normalized.slice(0, -1); - if (!candidate || candidate === '.' || candidate === '..') { - break; - } - - const basename = candidate.replace(/\/$/, '').split('/').pop() || candidate; - const extension = basename.includes('.') ? basename.split('.').pop()?.toLowerCase() || '' : ''; - const looksLikeDirectory = candidate.includes('/') && !basename.includes('.'); - const looksLikeKnownFile = KNOWN_FILE_BASENAMES.has(basename) || (Boolean(extension) && KNOWN_FILE_EXTENSIONS.has(extension)); - - if (!looksLikeDirectory && !looksLikeKnownFile) { - break; - } - - normalized = candidate; - } - - return normalized; -}; - const isAbsolutePath = (value: string) => value.startsWith('/') || WINDOWS_ABS_PATTERN.test(value); - const toIsoTimestamp = (value: string | number | Date | undefined): string => { const date = value ? new Date(value) : new Date(); - if (Number.isNaN(date.getTime())) { - if (process.env.NODE_ENV !== 'production') { - console.warn('[sessionContextSummary] Invalid date value, falling back to now:', value); - } - return new Date().toISOString(); - } - return date.toISOString(); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); }; const parseJsonValue = (value: unknown): any => { @@ -216,7 +98,7 @@ const parseJsonValue = (value: unknown): any => { }; const toRelativePath = (filePath: string, projectRoot: string): string | null => { - const normalizedPath = trimTrailingPathPunctuation(String(filePath || '').trim()); + const normalizedPath = normalizePath(String(filePath || '').trim()); if (!normalizedPath) { return null; } @@ -229,9 +111,8 @@ const toRelativePath = (filePath: string, projectRoot: string): string | null => return normalizedPath.replace(/^\.\//, ''); }; - const toAbsolutePath = (filePath: string, projectRoot: string): string | null => { - const normalizedPath = trimTrailingPathPunctuation(String(filePath || '').trim()); + const normalizedPath = normalizePath(String(filePath || '').trim()); if (!normalizedPath) { return null; } @@ -276,14 +157,6 @@ const extractFilePathsFromResult = (toolResult: any): string[] => { } }); } - - if (source.changes && typeof source.changes === 'object') { - Object.keys(source.changes).forEach((filePath) => { - if (typeof filePath === 'string' && filePath.trim()) { - candidates.push(filePath.trim()); - } - }); - } }); return Array.from(new Set(candidates)); @@ -309,11 +182,14 @@ const extractTodos = (toolInput: any, toolResult: any): Array<{ label: string; d return []; }; -const extractSkillName = (message: ChatMessage): string | null => { - if (message.toolName === 'activate_skill' || message.toolName === 'Skill') { +const extractSkillContext = (message: ChatMessage): { label: string; path?: string } | null => { + if (message.toolName === 'activate_skill') { const parsedInput = parseJsonValue(message.toolInput) || {}; const skillName = parsedInput?.name || parsedInput?.skill; - return typeof skillName === 'string' && skillName.trim() ? skillName.trim() : null; + if (typeof skillName === 'string' && skillName.trim()) { + return { label: skillName.trim() }; + } + return null; } if (!message.isSkillContent || typeof message.content !== 'string') { @@ -321,342 +197,30 @@ const extractSkillName = (message: ChatMessage): string | null => { } const commandMatch = message.content.match(/([^<]+)<\/command-name>/i); - if (commandMatch?.[1]?.trim()) { - return commandMatch[1].trim(); - } + const pathMatch = message.content.match(/Base directory for this skill:\s*(\S+)/i); + const normalizedPath = pathMatch?.[1] + ? normalizePath(pathMatch[1].trim()) + : undefined; - // Detect skillExpander format: "# Skill: skill-name" heading - const skillHeadingMatch = message.content.match(/^#\s+Skill:\s*(\S+)/m); - if (skillHeadingMatch?.[1]?.trim()) { - return skillHeadingMatch[1].trim(); + if (commandMatch?.[1]?.trim()) { + return { + label: commandMatch[1].trim(), + path: normalizedPath, + }; } - const pathMatch = message.content.match(/Base directory for this skill:\s*(\S+)/i); - if (pathMatch?.[1]) { - const normalized = normalizePath(pathMatch[1].trim()); + if (normalizedPath) { + const normalized = normalizedPath; const parts = normalized.split('/'); - return parts[parts.length - 1] || normalized; + return { + label: parts[parts.length - 1] || normalized, + path: normalized, + }; } return null; }; -const extractToolInputPaths = (toolInput: any): string[] => { - const parsedInput = parseJsonValue(toolInput) || toolInput || {}; - const candidates = new Set(); - - [ - parsedInput?.file_path, - parsedInput?.path, - parsedInput?.filePath, - parsedInput?.absolutePath, - parsedInput?.relativePath, - ].forEach((value) => { - if (typeof value === 'string' && value.trim()) { - candidates.add(value.trim()); - } - }); - - [parsedInput?.file_paths, parsedInput?.paths].forEach((list) => { - if (Array.isArray(list)) { - list.forEach((value) => { - if (typeof value === 'string' && value.trim()) { - candidates.add(value.trim()); - } - }); - } - }); - - return Array.from(candidates); -}; - -const extractPlanItems = (toolInput: any): Array<{ label: string; detail?: string }> => { - const parsedInput = parseJsonValue(toolInput) || toolInput || {}; - if (!Array.isArray(parsedInput?.plan)) { - return []; - } - - return parsedInput.plan - .map((item: any) => ({ - label: typeof item?.step === 'string' ? item.step.trim() : '', - detail: typeof item?.status === 'string' ? item.status.trim() : undefined, - })) - .filter((item: { label: string }) => item.label); -}; - -const stripShellToken = (token: string) => token.replace(/^['"`]|['"`]$/g, ''); - -const isLikelyDirectoryPath = (value: string) => { - const normalized = normalizePath(value).replace(/\/$/, ''); - const basename = normalized.split('/').pop() || normalized; - return !basename.includes('.') && !KNOWN_FILE_BASENAMES.has(basename); -}; - -const isExplicitPathReference = (value: string) => - value.startsWith('/') || value.startsWith('./') || value.startsWith('../'); - -const looksLikePathToken = (value: string) => { - const normalized = trimTrailingPathPunctuation(value); - if (!normalized || normalized.startsWith('-') || normalized.includes('://') || SHELL_COMMAND_BREAKS.has(normalized)) { - return false; - } - - if (!/^[A-Za-z0-9._/-]+$/.test(normalized)) { - return false; - } - - const basename = normalized.split('/').pop() || normalized; - if (KNOWN_FILE_BASENAMES.has(basename)) { - return true; - } - - const extension = basename.includes('.') ? basename.split('.').pop()?.toLowerCase() || '' : ''; - if (Boolean(extension) && KNOWN_FILE_EXTENSIONS.has(extension)) { - return true; - } - - if (normalized.startsWith('/') || normalized.startsWith('./') || normalized.startsWith('../') || normalized.includes('/')) { - return !basename.includes('.'); - } - - return false; -}; - -const shouldTrackDirectoryCandidate = (value: string) => { - const normalized = trimTrailingPathPunctuation(value); - if (!normalized || !isLikelyDirectoryPath(normalized)) { - return false; - } - - if (isExplicitPathReference(normalized)) { - return true; - } - - return false; -}; - -const shouldTrackTextPathCandidate = (value: string) => { - const normalized = trimTrailingPathPunctuation(value); - if (!normalized || normalized.includes('://')) { - return false; - } - - if (isExplicitPathReference(normalized)) { - return true; - } - - const basename = normalized.split('/').pop() || normalized; - if (KNOWN_FILE_BASENAMES.has(basename)) { - return true; - } - - const extension = basename.includes('.') ? basename.split('.').pop()?.toLowerCase() || '' : ''; - return Boolean(extension) && KNOWN_FILE_EXTENSIONS.has(extension); -}; - -const extractPathsFromText = (value: string): string[] => { - if (!value) return []; - - const candidates = new Set(); - const pushMatch = (matchValue: string) => { - const normalized = trimTrailingPathPunctuation(matchValue); - if (!shouldTrackTextPathCandidate(normalized)) return; - candidates.add(normalized); - }; - - Array.from(value.matchAll(MARKDOWN_FILE_LINK_PATTERN)).forEach((match) => { - if (match[1]) pushMatch(match[1]); - }); - - Array.from(value.matchAll(ABSOLUTE_PATH_IN_TEXT_PATTERN)).forEach((match) => { - if (match[1]) pushMatch(match[1]); - }); - - Array.from(value.matchAll(RELATIVE_PATH_IN_TEXT_PATTERN)).forEach((match) => { - if (match[1]) pushMatch(match[1]); - }); - - return Array.from(candidates); -}; - -const extractShellContext = ( - toolInput: any, - toolResult: any, -): { files: string[]; directories: string[] } => { - const parsedInput = parseJsonValue(toolInput) || toolInput || {}; - const command = String(parsedInput?.command || parsedInput?.cmd || '').trim(); - const parsedCommands = Array.isArray(parsedInput?.parsed_cmd) ? parsedInput.parsed_cmd : []; - const files = new Set(); - const directories = new Set(); - - parsedCommands.forEach((entry: any) => { - const nextPath = typeof entry?.path === 'string' ? entry.path.trim() : ''; - if (!nextPath) return; - if (entry?.type === 'read') { - files.add(nextPath); - return; - } - if (entry?.type === 'list_files') { - directories.add(nextPath); - return; - } - if (entry?.type === 'search') { - if (nextPath) directories.add(nextPath); - } - }); - - (command.match(SHELL_TOKEN_PATTERN) || []) - .map(stripShellToken) - .forEach((token) => { - if (!looksLikePathToken(token)) { - const colonPath = token.includes(':') ? token.slice(token.indexOf(':') + 1) : ''; - if (colonPath && looksLikePathToken(colonPath)) { - const normalized = trimTrailingPathPunctuation(colonPath); - if (isLikelyDirectoryPath(normalized)) { - if (shouldTrackDirectoryCandidate(normalized)) { - directories.add(normalized); - } - } else { - files.add(normalized); - } - } - return; - } - - const normalized = trimTrailingPathPunctuation(token); - if (isLikelyDirectoryPath(normalized)) { - if (shouldTrackDirectoryCandidate(normalized)) { - directories.add(normalized); - } - } else { - files.add(normalized); - } - }); - - extractPathsFromText(String(toolResult?.content || '')).forEach((nextPath) => { - if (shouldTrackDirectoryCandidate(nextPath)) { - directories.add(nextPath); - } else { - files.add(nextPath); - } - }); - - return { - files: Array.from(files), - directories: Array.from(directories), - }; -}; - -const collectProjectRootCandidate = (target: string[], candidate: unknown) => { - if (typeof candidate !== 'string' || !candidate.trim()) { - return; - } - - const normalized = trimTrailingPathPunctuation(candidate).replace(/\/$/, ''); - if (!normalized || !isAbsolutePath(normalized)) { - return; - } - - if (isLikelyDirectoryPath(normalized)) { - target.push(normalized); - return; - } - - const lastSlashIndex = normalized.lastIndexOf('/'); - if (lastSlashIndex > 0) { - target.push(normalized.slice(0, lastSlashIndex)); - } -}; - -const longestCommonPathPrefix = (paths: string[]): string => { - if (paths.length === 0) { - return ''; - } - - const parts = paths - .map((value) => normalizePath(value).replace(/\/$/, '')) - .filter(Boolean) - .map((value) => value.split('/')); - - if (parts.length === 0) { - return ''; - } - - let sharedLength = parts[0].length; - for (let index = 1; index < parts.length; index += 1) { - sharedLength = Math.min(sharedLength, parts[index].length); - for (let segmentIndex = 0; segmentIndex < sharedLength; segmentIndex += 1) { - if (parts[0][segmentIndex] !== parts[index][segmentIndex]) { - sharedLength = segmentIndex; - break; - } - } - } - - if (sharedLength === 0) { - return ''; - } - - const prefix = parts[0].slice(0, sharedLength).join('/'); - return prefix === '/' ? '' : prefix; -}; - -export const resolveSessionContextProjectRoot = (messages: ChatMessage[], preferredRoot = ''): string => { - const normalizedPreferredRoot = normalizePath(String(preferredRoot || '').trim()).replace(/\/$/, ''); - const explicitRoots: string[] = []; - const absolutePathRoots: string[] = []; - - messages.forEach((message) => { - collectProjectRootCandidate(explicitRoots, message.cwd); - collectProjectRootCandidate(explicitRoots, message.workdir); - collectProjectRootCandidate(explicitRoots, message.projectPath); - - const parsedInput = parseJsonValue(message.toolInput) || message.toolInput || {}; - collectProjectRootCandidate(explicitRoots, parsedInput?.cwd); - collectProjectRootCandidate(explicitRoots, parsedInput?.workdir); - collectProjectRootCandidate(explicitRoots, parsedInput?.projectPath); - - extractToolInputPaths(parsedInput).forEach((filePath) => { - collectProjectRootCandidate(absolutePathRoots, filePath); - }); - extractFilePathsFromResult(message.toolResult).forEach((filePath) => { - collectProjectRootCandidate(absolutePathRoots, filePath); - }); - - if (message.toolName === 'Bash' || message.toolName === 'exec_command') { - const shellContext = extractShellContext(parsedInput, message.toolResult); - shellContext.files.forEach((filePath) => { - collectProjectRootCandidate(absolutePathRoots, filePath); - }); - shellContext.directories.forEach((directoryPath) => { - collectProjectRootCandidate(absolutePathRoots, directoryPath); - }); - } - }); - - const uniqueExplicitRoots = Array.from(new Set(explicitRoots)); - if (normalizedPreferredRoot && uniqueExplicitRoots.some((root) => root === normalizedPreferredRoot || root.startsWith(`${normalizedPreferredRoot}/`))) { - return normalizedPreferredRoot; - } - - const explicitRootPrefix = longestCommonPathPrefix(uniqueExplicitRoots); - if (explicitRootPrefix) { - return explicitRootPrefix; - } - - const uniqueAbsolutePathRoots = Array.from(new Set(absolutePathRoots)); - if (normalizedPreferredRoot && uniqueAbsolutePathRoots.some((root) => root === normalizedPreferredRoot || root.startsWith(`${normalizedPreferredRoot}/`))) { - return normalizedPreferredRoot; - } - - const absolutePathRootPrefix = longestCommonPathPrefix(uniqueAbsolutePathRoots); - if (absolutePathRootPrefix) { - return absolutePathRootPrefix; - } - - return normalizedPreferredRoot; -}; - const addFile = ( target: Map, filePath: string, @@ -714,8 +278,8 @@ const addTask = ( if (timestamp > existing.lastSeenAt) { existing.lastSeenAt = timestamp; existing.detail = detail || existing.detail; + existing.path = path || existing.path; } - existing.path = path || existing.path; return; } @@ -723,8 +287,8 @@ const addTask = ( key, label: normalizedLabel, detail: detail || undefined, - path: path || undefined, kind, + path: path || undefined, count: 1, lastSeenAt: timestamp, }); @@ -801,7 +365,6 @@ export function deriveSessionContextSummary( projectRoot: string, reviews: SessionReviewState = {}, ): SessionContextSummary { - const effectiveProjectRoot = resolveSessionContextProjectRoot(messages, projectRoot); const contextFiles = new Map(); const outputFiles = new Map(); const tasks = new Map(); @@ -811,21 +374,13 @@ export function deriveSessionContextSummary( messages.forEach((message) => { const timestamp = toIsoTimestamp(message.timestamp); - const skillName = extractSkillName(message); - if (skillName) { - // Try to extract SKILL.md path from "Base directory for this skill:" in isSkillContent messages - let skillPath: string | undefined; - if (message.isSkillContent && typeof message.content === 'string') { - const baseMatch = message.content.match(/Base directory for this skill:\s*(\S+)/i); - if (baseMatch?.[1]) { - skillPath = `${baseMatch[1].trim().replace(/\/$/, '')}/SKILL.md`; - } - } - addTask(skills, 'skill', skillName, undefined, timestamp, skillPath); + const skill = extractSkillContext(message); + if (skill) { + addTask(skills, 'skill', skill.label, undefined, timestamp, skill.path); } if (message.isTaskNotification && typeof message.taskOutputFile === 'string' && message.taskOutputFile.trim()) { - addFile(outputFiles, message.taskOutputFile, effectiveProjectRoot, 'Task output', timestamp); + addFile(outputFiles, message.taskOutputFile, projectRoot, 'Task output', timestamp); if (message.taskId) { addTask(tasks, 'task', `Task ${message.taskId}`, message.content || undefined, timestamp); } @@ -840,13 +395,10 @@ export function deriveSessionContextSummary( switch (message.toolName) { case 'Read': { - extractToolInputPaths(parsedInput).forEach((filePath) => { - addFile(contextFiles, filePath, effectiveProjectRoot, 'Read', timestamp); - const skillMatch = filePath.match(SKILL_MD_PATH_RE); - if (skillMatch?.[1]) { - addTask(skills, 'skill', skillMatch[1], undefined, timestamp, filePath); - } - }); + const filePath = parsedInput?.file_path || parsedInput?.path; + if (typeof filePath === 'string') { + addFile(contextFiles, filePath, projectRoot, 'Read', timestamp); + } break; } @@ -854,23 +406,7 @@ export function deriveSessionContextSummary( case 'Glob': { const searchReason = message.toolName || 'Search'; extractFilePathsFromResult(message.toolResult).forEach((filePath) => { - addFile(contextFiles, filePath, effectiveProjectRoot, searchReason, timestamp); - }); - break; - } - - case 'Bash': - case 'exec_command': { - const shellContext = extractShellContext(parsedInput, message.toolResult); - shellContext.files.forEach((filePath) => { - addFile(contextFiles, filePath, effectiveProjectRoot, 'Shell', timestamp); - const skillMatch = filePath.match(SKILL_MD_PATH_RE); - if (skillMatch?.[1]) { - addTask(skills, 'skill', skillMatch[1], undefined, timestamp, filePath); - } - }); - shellContext.directories.forEach((directoryPath) => { - addTask(directories, 'directory', toRelativePath(directoryPath, effectiveProjectRoot) || directoryPath, 'Referenced in shell command', timestamp); + addFile(contextFiles, filePath, projectRoot, searchReason, timestamp); }); break; } @@ -878,7 +414,7 @@ export function deriveSessionContextSummary( case 'LS': { const directoryPath = parsedInput?.dir_path || parsedInput?.path || '.'; if (typeof directoryPath === 'string' && directoryPath.trim()) { - addTask(directories, 'directory', toRelativePath(directoryPath, effectiveProjectRoot) || directoryPath, 'Listed by LS', timestamp); + addTask(directories, 'directory', toRelativePath(directoryPath, projectRoot) || directoryPath, 'Listed by LS', timestamp); } break; } @@ -911,51 +447,48 @@ export function deriveSessionContextSummary( break; } - case 'UpdatePlan': - case 'update_plan': { - const planItems = extractPlanItems(parsedInput); + case 'UpdatePlan': { + const planItems = Array.isArray(parsedInput?.plan) ? parsedInput.plan : []; if (planItems.length === 0) { - addTask(tasks, 'task', 'Plan updated', undefined, timestamp); + addTask(tasks, 'task', 'Plan update', 'Plan updated', timestamp); } else { - planItems.forEach((item) => { - addTask(tasks, 'todo', item.label, item.detail, timestamp); + planItems.forEach((item: any, index: number) => { + const label = + (typeof item?.step === 'string' && item.step.trim()) + || (typeof item?.title === 'string' && item.title.trim()) + || `Plan step ${index + 1}`; + const status = typeof item?.status === 'string' ? item.status.trim() : ''; + addTask(tasks, 'task', label, status || 'Plan update', timestamp); }); } break; } case 'Write': { - extractToolInputPaths(parsedInput).forEach((filePath) => { - addFile(outputFiles, filePath, effectiveProjectRoot, 'Write', timestamp); - }); + const filePath = parsedInput?.file_path || parsedInput?.path; + if (typeof filePath === 'string') { + addFile(outputFiles, filePath, projectRoot, 'Write', timestamp); + } break; } case 'Edit': case 'ApplyPatch': { - const outputPaths = new Set([ - ...extractToolInputPaths(parsedInput), - ...extractFilePathsFromResult(message.toolResult), - ]); - outputPaths.forEach((filePath) => { - addFile(outputFiles, filePath, effectiveProjectRoot, message.toolName === 'Edit' ? 'Edit' : 'Patch', timestamp); - }); + const filePath = parsedInput?.file_path || parsedInput?.path; + if (typeof filePath === 'string') { + addFile(outputFiles, filePath, projectRoot, message.toolName === 'Edit' ? 'Edit' : 'Patch', timestamp); + } break; } case 'FileChanges': { parseFileChanges(message.toolInput).forEach((filePath) => { - addFile(outputFiles, filePath, effectiveProjectRoot, 'File change', timestamp); - const skillMatch = filePath.match(SKILL_MD_PATH_RE); - if (skillMatch?.[1]) { - addTask(skills, 'skill', skillMatch[1], undefined, timestamp, filePath); - } + addFile(outputFiles, filePath, projectRoot, 'File change', timestamp); }); break; } - case 'activate_skill': - case 'Skill': { + case 'activate_skill': { const skillLabel = parsedInput?.name || parsedInput?.skill; if (typeof skillLabel === 'string' && skillLabel.trim()) { addTask(skills, 'skill', skillLabel.trim(), 'Activated in session', timestamp); @@ -963,34 +496,48 @@ export function deriveSessionContextSummary( break; } - case 'ViewImage': { - extractToolInputPaths(parsedInput).forEach((filePath) => { - addFile(contextFiles, filePath, effectiveProjectRoot, 'Image view', timestamp); - }); - break; - } - case 'WebSearch': { - const query = parsedInput?.query || parsedInput?.command; - if (typeof query === 'string' && query.trim()) { - addTask(tasks, 'task', query.trim(), 'Web search', timestamp); + const queries: unknown[] = Array.isArray(parsedInput?.queries) + ? (parsedInput.queries as unknown[]) + : []; + const normalizedQueries: string[] = queries + .map((entry: unknown) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry): entry is string => Boolean(entry)); + if (typeof parsedInput?.query === 'string' && parsedInput.query.trim()) { + normalizedQueries.unshift(parsedInput.query.trim()); + } + + if (normalizedQueries.length === 0) { + addTask(tasks, 'task', 'Web search', 'Search requested', timestamp); + } else { + normalizedQueries.forEach((query) => { + addTask(tasks, 'task', query, 'Web search query', timestamp); + }); } break; } case 'OpenPage': { - const url = parsedInput?.url; - if (typeof url === 'string' && url.trim()) { - addTask(tasks, 'task', url.trim(), 'Opened web page', timestamp); + const url = typeof parsedInput?.url === 'string' ? parsedInput.url.trim() : ''; + if (url) { + addTask(tasks, 'task', url, 'Opened web page', timestamp); + } else { + addTask(tasks, 'task', 'Open page', 'Web page opened', timestamp); } break; } case 'FindInPage': { - const pattern = parsedInput?.pattern; - const url = parsedInput?.url; - if (typeof pattern === 'string' && pattern.trim()) { - addTask(tasks, 'task', pattern.trim(), typeof url === 'string' && url.trim() ? `Find in ${url.trim()}` : 'Find in page', timestamp); + const url = typeof parsedInput?.url === 'string' ? parsedInput.url.trim() : ''; + const pattern = typeof parsedInput?.pattern === 'string' ? parsedInput.pattern.trim() : ''; + if (url) { + addTask(tasks, 'task', url, 'Find in page target', timestamp); + } + if (pattern) { + addTask(tasks, 'task', pattern, 'Find in page pattern', timestamp); + } + if (!url && !pattern) { + addTask(tasks, 'task', 'Find in page', 'In-page search', timestamp); } break; } diff --git a/src/components/chat/utils/sessionFilterDebug.ts b/src/components/chat/utils/sessionFilterDebug.ts new file mode 100644 index 00000000..b0b219b8 --- /dev/null +++ b/src/components/chat/utils/sessionFilterDebug.ts @@ -0,0 +1,86 @@ +import type { SessionProvider } from "../../../types/app"; + +const SESSION_FILTER_DEBUG_LOCAL_STORAGE_KEY = "session_filter_debug"; +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +export interface SessionFilterDebugPayload { + reason: string; + messageType?: string | null; + routedSessionId?: string | null; + actualSessionId?: string | null; + sessionProvider?: SessionProvider | string | null; + messageProjectName?: string | null; + activeViewSessionId?: string | null; + activeViewProvider?: SessionProvider | string | null; + activeViewProjectName?: string | null; + isGlobalMessage?: boolean; + isPendingViewSession?: boolean; + shouldRebindCodexTemporarySession?: boolean; + canUseActiveTemporarySessionForCodex?: boolean; + isUnscopedError?: boolean; + shouldBypassSessionFilter?: boolean; + extra?: Record; +} + +type SendMessageFn = ((message: Record) => void) | undefined; + +export function isSessionFilterDebugEnabled(): boolean { + if (typeof window === "undefined") { + return false; + } + + const rawValue = window.localStorage + .getItem(SESSION_FILTER_DEBUG_LOCAL_STORAGE_KEY) + ?.trim() + .toLowerCase(); + if (!rawValue) { + return false; + } + return TRUE_VALUES.has(rawValue); +} + +export function syncSessionFilterDebugSetting(sendMessage?: SendMessageFn): void { + if (!sendMessage) { + return; + } + + const enabled = isSessionFilterDebugEnabled(); + if (!enabled) { + return; + } + + sendMessage({ + type: "session-filter-debug-settings", + enabled, + source: "frontend", + }); +} + +export function emitSessionFilterDebugLog( + payload: SessionFilterDebugPayload, + sendMessage?: SendMessageFn, +): void { + if (!isSessionFilterDebugEnabled()) { + return; + } + + const normalizedPayload = { + ...payload, + loggedAt: Date.now(), + }; + + if (typeof window !== "undefined") { + console.debug("[session-filter-debug]", normalizedPayload); + } + + if (!sendMessage) { + return; + } + + sendMessage({ + type: "session-filter-debug", + source: "frontend", + reason: payload.reason, + payload: normalizedPayload, + }); +} diff --git a/src/components/chat/utils/sessionLoadGuards.ts b/src/components/chat/utils/sessionLoadGuards.ts new file mode 100644 index 00000000..03216468 --- /dev/null +++ b/src/components/chat/utils/sessionLoadGuards.ts @@ -0,0 +1,22 @@ +import type { Provider } from '../types/types'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; + +export function resolveSessionLoadProvider(provider: Provider | string | null | undefined): Provider { + return normalizeProvider((provider || DEFAULT_PROVIDER) as Provider); +} + +export function shouldSkipSessionMessageLoad(sessionId: string | null | undefined): boolean { + if (!sessionId) { + return false; + } + return sessionId.startsWith('new-session-') || sessionId.startsWith('temp-'); +} + +export function shouldApplySessionLoadResult( + requestId: number, + activeRequestId: number, + cancelled: boolean, +): boolean { + return !cancelled && requestId === activeRequestId; +} + diff --git a/src/components/chat/utils/sessionMessageCache.ts b/src/components/chat/utils/sessionMessageCache.ts new file mode 100644 index 00000000..232da811 --- /dev/null +++ b/src/components/chat/utils/sessionMessageCache.ts @@ -0,0 +1,24 @@ +import type { Provider } from '../types/types'; +import { buildChatMessagesStorageKey } from './chatStorage'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; + +const LEGACY_CHAT_MESSAGES_PREFIX = 'chat_messages_'; + +export function buildSessionMessageCacheCandidateKeys( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, +): string[] { + if (!projectName || !sessionId) { + return []; + } + + const normalizedProvider = normalizeProvider((provider || DEFAULT_PROVIDER) as Provider); + return Array.from( + new Set([ + buildChatMessagesStorageKey(projectName, sessionId, normalizedProvider), + buildChatMessagesStorageKey(projectName, sessionId, DEFAULT_PROVIDER), + `${LEGACY_CHAT_MESSAGES_PREFIX}${projectName}_${sessionId}`, + ].filter(Boolean)), + ); +} diff --git a/src/components/chat/utils/sessionSnapshotCache.ts b/src/components/chat/utils/sessionSnapshotCache.ts new file mode 100644 index 00000000..c0ebc813 --- /dev/null +++ b/src/components/chat/utils/sessionSnapshotCache.ts @@ -0,0 +1,60 @@ +import type { ChatMessage, Provider } from '../types/types'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; + +export type SessionSnapshot = { + provider: Provider; + sessionMessages: unknown[]; + chatMessages: ChatMessage[]; + updatedAt: number; +}; + +function cloneArrayShallow(items: T[] | null | undefined): T[] { + if (!Array.isArray(items)) { + return []; + } + + return items.map((item) => { + if (item && typeof item === 'object' && !Array.isArray(item)) { + return { ...(item as Record) } as T; + } + return item; + }); +} + +export function normalizeSessionSnapshotProvider(provider: Provider | string | null | undefined): Provider { + return normalizeProvider((provider || DEFAULT_PROVIDER) as Provider); +} + +export function buildSessionSnapshotKey( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, +): string { + if (!projectName || !sessionId) { + return ''; + } + + const normalizedProvider = normalizeSessionSnapshotProvider(provider); + return `${projectName}::${sessionId}::${normalizedProvider}`; +} + +export function createSessionSnapshot( + provider: Provider | string | null | undefined, + sessionMessages: unknown[] | null | undefined, + chatMessages: ChatMessage[] | null | undefined, +): SessionSnapshot { + return { + provider: normalizeSessionSnapshotProvider(provider), + sessionMessages: cloneArrayShallow(sessionMessages), + chatMessages: cloneArrayShallow(chatMessages), + updatedAt: Date.now(), + }; +} + +export function cloneSessionSnapshot(snapshot: SessionSnapshot): SessionSnapshot { + return { + ...snapshot, + sessionMessages: cloneArrayShallow(snapshot.sessionMessages), + chatMessages: cloneArrayShallow(snapshot.chatMessages), + }; +} diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index bd0b7802..50463cf4 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -232,6 +232,7 @@ function ChatInterface({ } = useChatSessionState({ selectedProject, selectedSession, + activeProvider: provider, ws, sendMessage, autoScrollToBottom, @@ -361,6 +362,7 @@ function ChatInterface({ onSessionStatusResolved: resolveSessionStatusCheck, onReplaceTemporarySession, onNavigateToSession, + sendMessage, }); const handleRetry = useCallback(() => { @@ -972,7 +974,7 @@ function ChatInterface({ onSidebarTabChange={setSidebarTab} isCollapsed={isSidebarCollapsed} onCollapsedChange={setIsSidebarCollapsed} - onStartWorkspaceQa={onStartWorkspaceQa} + onStartWorkspaceQa={onStartWorkspaceQa ?? undefined} onStartTask={handleStartTaskInChat} /> diff --git a/src/components/chat/view/subcomponents/ChatContextFilePreview.tsx b/src/components/chat/view/subcomponents/ChatContextFilePreview.tsx index 539d2b4e..0de6c8f1 100644 --- a/src/components/chat/view/subcomponents/ChatContextFilePreview.tsx +++ b/src/components/chat/view/subcomponents/ChatContextFilePreview.tsx @@ -8,17 +8,26 @@ import { ExternalLink, FileText, X } from 'lucide-react'; import { Button } from '../../../ui/button'; import { api } from '../../../../utils/api'; -import { IMAGE_EXTENSIONS, AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, MARKDOWN_EXTENSIONS, HTML_EXTENSIONS } from '../../utils/fileExtensions'; +import type { SessionContextFileItem, SessionContextOutputItem } from '../../utils/sessionContextSummary'; + +export type PreviewFileTarget = + | SessionContextFileItem + | SessionContextOutputItem + | { + name?: string; + relativePath: string; + absolutePath?: string; + }; -export interface PreviewFileTarget { - name: string; - relativePath: string; - absolutePath: string | null; -} +const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']); +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']); +const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'webm', 'mkv']); +const MARKDOWN_EXTENSIONS = new Set(['md', 'mdx']); +const HTML_EXTENSIONS = new Set(['html', 'htm']); type PreviewFile = PreviewFileTarget | null; -type PreviewKind = 'empty' | 'loading' | 'text' | 'json' | 'markdown' | 'html' | 'pdf' | 'image' | 'audio' | 'video' | 'error'; +type PreviewKind = 'empty' | 'loading' | 'text' | 'markdown' | 'html' | 'pdf' | 'image' | 'audio' | 'video' | 'error'; const getPreviewKind = (file: PreviewFile): PreviewKind => { if (!file) { @@ -32,61 +41,11 @@ const getPreviewKind = (file: PreviewFile): PreviewKind => { if (IMAGE_EXTENSIONS.has(extension)) return 'image'; if (AUDIO_EXTENSIONS.has(extension)) return 'audio'; if (VIDEO_EXTENSIONS.has(extension)) return 'video'; - if (extension === 'json') return 'json'; - if (MARKDOWN_EXTENSIONS.has(extension) || extension === 'txt') return 'markdown'; + if (MARKDOWN_EXTENSIONS.has(extension)) return 'markdown'; if (HTML_EXTENSIONS.has(extension)) return 'html'; return 'text'; }; -const JSON_TOKEN_RE = /("(?:[^"\\]|\\.)*")\s*:|("(?:[^"\\]|\\.)*")|(\b(?:true|false)\b)|(\bnull\b)|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)/g; - -function highlightJsonLine(line: string): (string | JSX.Element)[] { - const parts: (string | JSX.Element)[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - - JSON_TOKEN_RE.lastIndex = 0; - while ((match = JSON_TOKEN_RE.exec(line)) !== null) { - if (match.index > lastIndex) { - parts.push(line.slice(lastIndex, match.index)); - } - - const [full, key, str, bool, nul, num] = match; - - if (key !== undefined) { - // key (without the colon) - parts.push({key}); - parts.push(':'); - } else if (str !== undefined) { - parts.push({str}); - } else if (bool !== undefined) { - parts.push({bool}); - } else if (nul !== undefined) { - parts.push({nul}); - } else if (num !== undefined) { - parts.push({num}); - } else { - parts.push(full); - } - - lastIndex = match.index + full.length; - } - - if (lastIndex < line.length) { - parts.push(line.slice(lastIndex)); - } - - return parts; -} - -function JsonHighlight({ content }: { content: string }) { - const rendered = useMemo(() => { - const lines = content.split('\n'); - return lines.map((line, i) =>
{highlightJsonLine(line)}
); - }, [content]); - return <>{rendered}; -} - interface ChatContextFilePreviewProps { projectName: string; file: PreviewFile; @@ -102,7 +61,7 @@ export default function ChatContextFilePreview({ onOpenInEditor, onClose, compact = false, - preloadedContent, + preloadedContent = null, }: ChatContextFilePreviewProps) { const { t } = useTranslation('chat'); const [content, setContent] = useState(''); @@ -112,7 +71,7 @@ export default function ChatContextFilePreview({ const previewKind = useMemo(() => getPreviewKind(file), [file]); useEffect(() => { - const abortController = new AbortController(); + let cancelled = false; let objectUrl: string | null = null; setContent(''); @@ -140,8 +99,8 @@ export default function ChatContextFilePreview({ try { if (previewKind === 'pdf' || previewKind === 'image' || previewKind === 'audio' || previewKind === 'video') { const absolutePath = file.absolutePath || file.relativePath; - const blob = await api.getFileContentBlob(projectName, absolutePath, { signal: abortController.signal }); - if (abortController.signal.aborted) { + const blob = await api.getFileContentBlob(projectName, absolutePath); + if (cancelled) { return; } @@ -150,46 +109,31 @@ export default function ChatContextFilePreview({ return; } - const response = await api.readFile(projectName, file.relativePath, { signal: abortController.signal }); + const response = await api.readFile(projectName, file.relativePath); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const rawText = await response.text(); - if (abortController.signal.aborted) { + if (cancelled) { return; } - if (previewKind === 'json') { - try { - const wrapper = JSON.parse(rawText); - const raw = typeof wrapper?.content === 'string' ? wrapper.content : rawText; - try { - const parsed = JSON.parse(raw); - setContent(JSON.stringify(parsed, null, 2)); - } catch { - setContent(raw); - } - } catch { - setContent(rawText); - } - } else { - try { - const parsed = JSON.parse(rawText); - const nextContent = typeof parsed?.content === 'string' - ? parsed.content - : JSON.stringify(parsed?.content ?? parsed, null, 2); - setContent(nextContent); - } catch { - setContent(rawText); - } + try { + const parsed = JSON.parse(rawText); + const nextContent = typeof parsed?.content === 'string' + ? parsed.content + : JSON.stringify(parsed?.content ?? parsed, null, 2); + setContent(nextContent); + } catch { + setContent(rawText); } } catch (error) { - if (!abortController.signal.aborted) { + if (!cancelled) { setLoadError(error instanceof Error ? error.message : 'Failed to load preview.'); } } finally { - if (!abortController.signal.aborted) { + if (!cancelled) { setLoading(false); } } @@ -198,7 +142,7 @@ export default function ChatContextFilePreview({ void loadPreview(); return () => { - abortController.abort(); + cancelled = true; if (objectUrl) { URL.revokeObjectURL(objectUrl); } @@ -297,7 +241,7 @@ export default function ChatContextFilePreview({