diff --git a/CHANGELOG.md b/CHANGELOG.md index eb35ff60..e62245cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # 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. + +#### Migration notes +- **Message cache key change**: Session message cache keys are now scoped by provider (`chat_messages_{project}_{provider}_{session}`). Existing localStorage entries keyed the old way (`chat_messages_{project}_{session}`) will not be read by default. To migrate, set `allowLegacyFallback: true` when calling `getSessionMessageCacheKeys()`. Users upgrading from builds prior to this sync may see empty transcript history on first load for previously-active sessions; the data is still in localStorage and can be recovered by enabling the legacy fallback or manually re-keying the entries. + +#### 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..a7d2cbe3 --- /dev/null +++ b/server/__tests__/codex-session-events.test.mjs @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCodexSessionCreatedEvent } from '../utils/codexSessionEvents.js'; + +describe('codex session event payloads', () => { + it('includes projectName when provided', () => { + const projectName = 'C--Users-test-user-dr-claw-project'; + const event = buildCodexSessionCreatedEvent({ + sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade', + sessionMode: 'research', + projectName, + }); + + expect(event).toEqual({ + type: 'session-created', + sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade', + provider: 'codex', + mode: 'research', + projectName, + }); + }); + + 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..675e0314 100644 --- a/server/__tests__/project-sync-dedup.test.mjs +++ b/server/__tests__/project-sync-dedup.test.mjs @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, mkdir, rm } from 'fs/promises'; +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -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,58 @@ 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); + }); + + it('does not assign unowned projects during anonymous bootstrap in multi-user mode', async () => { + const { projects, database } = await loadTestModules(); + createTestUser(database, 'multi-user-1'); + createTestUser(database, 'multi-user-2'); + + const workspaceRoot = path.join(tempRoot, 'dr-claw'); + const projectDir = path.join(workspaceRoot, 'ownerless-bootstrap-project'); + await mkdir(projectDir, { recursive: true }); + + const projectName = projects.encodeProjectPath(projectDir); + const configDir = path.join(tempRoot, '.dr-claw'); + await mkdir(configDir, { recursive: true }); + await writeFile( + path.join(configDir, 'project-config.json'), + JSON.stringify({ + [projectName]: { + originalPath: projectDir, + }, + }, null, 2), + 'utf8', + ); + + database.projectDb.upsertProject( + projectName, + null, + 'Ownerless Bootstrap Project', + projectDir, + 0, + null, + null, + ); + + await projects.getProjects(null); + + const dbRow = database.projectDb.getProjectById(projectName); + expect(dbRow).not.toBeNull(); + expect(dbRow.user_id).toBeNull(); + }); + }); }); 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/__tests__/session-lifecycle.test.mjs b/server/__tests__/session-lifecycle.test.mjs new file mode 100644 index 00000000..6eafbe96 --- /dev/null +++ b/server/__tests__/session-lifecycle.test.mjs @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; + +import { + inferProviderFromMessageType, + resolveProjectName, + enrichSessionEventPayload, + buildLifecycleMessageFromPayload, +} from '../utils/sessionLifecycle.js'; + +describe('inferProviderFromMessageType', () => { + it.each([ + ['claude-complete', 'claude'], + ['cursor-result', 'cursor'], + ['codex-complete', 'codex'], + ['gemini-complete', 'gemini'], + ['openrouter-complete', 'openrouter'], + ['localgpu-complete', 'local'], + ['nano-complete', 'nano'], + ])('infers %s → %s', (type, expected) => { + expect(inferProviderFromMessageType(type)).toBe(expected); + }); + + it('returns fallbackProvider when prefix is unknown', () => { + expect(inferProviderFromMessageType('unknown-type', 'codex')).toBe('codex'); + }); + + it('returns null when no prefix matches and no fallback', () => { + expect(inferProviderFromMessageType('unknown-type')).toBeNull(); + }); + + it('handles null/undefined type gracefully', () => { + expect(inferProviderFromMessageType(null)).toBeNull(); + expect(inferProviderFromMessageType(undefined)).toBeNull(); + }); +}); + +describe('resolveProjectName', () => { + it('returns explicit projectName when provided', () => { + expect(resolveProjectName('my-project', null)).toBe('my-project'); + }); + + it('returns null for empty projectName and no path', () => { + expect(resolveProjectName(null, null)).toBeNull(); + expect(resolveProjectName('', '')).toBeNull(); + expect(resolveProjectName(' ', null)).toBeNull(); + }); + + it('resolves from projectPath via deps when projectName is missing', () => { + const deps = { + isKnownPath: () => true, + encodePath: (p) => `encoded-${p}`, + }; + expect(resolveProjectName(null, '/some/path', deps)).toBe('encoded-/some/path'); + }); + + it('returns null when isKnownPath returns false', () => { + const deps = { + isKnownPath: () => false, + encodePath: () => 'should-not-be-called', + }; + expect(resolveProjectName(null, '/unknown/path', deps)).toBeNull(); + }); + + it('returns null when encodePath throws', () => { + const deps = { + isKnownPath: () => true, + encodePath: () => { throw new Error('encode failed'); }, + }; + expect(resolveProjectName(null, '/bad/path', deps)).toBeNull(); + }); + + it('returns null when deps are not provided and projectName is missing', () => { + expect(resolveProjectName(null, '/some/path')).toBeNull(); + }); +}); + +describe('enrichSessionEventPayload', () => { + const deps = { + isKnownPath: () => true, + encodePath: (p) => `encoded-${p}`, + }; + + it('returns non-object payloads unchanged', () => { + expect(enrichSessionEventPayload(null)).toBeNull(); + expect(enrichSessionEventPayload(undefined)).toBeUndefined(); + expect(enrichSessionEventPayload('string')).toBe('string'); + }); + + it('ignores non-session message types', () => { + const payload = { type: 'claude-complete', projectPath: '/p' }; + expect(enrichSessionEventPayload(payload, null, deps)).toBe(payload); + }); + + it('enriches session payload with resolved projectName from projectPath', () => { + const payload = { type: 'session-created', projectPath: '/my/project' }; + const result = enrichSessionEventPayload(payload, null, deps); + expect(result.projectName).toBe('encoded-/my/project'); + expect(result.type).toBe('session-created'); + }); + + it('uses fallbackProjectPath when payload has no projectPath', () => { + const payload = { type: 'session-created' }; + const result = enrichSessionEventPayload(payload, '/fallback/path', deps); + expect(result.projectName).toBe('encoded-/fallback/path'); + }); + + it('does not overwrite existing projectName', () => { + const payload = { type: 'session-created', projectName: 'already-set' }; + const result = enrichSessionEventPayload(payload, null, deps); + expect(result).toBe(payload); + }); + + it('returns original payload when resolved name matches existing', () => { + const depsMatch = { + isKnownPath: () => true, + encodePath: () => 'same-name', + }; + const payload = { type: 'session-created', projectName: 'same-name' }; + expect(enrichSessionEventPayload(payload, null, depsMatch)).toBe(payload); + }); +}); + +describe('buildLifecycleMessageFromPayload', () => { + it('returns null for non-object payloads', () => { + expect(buildLifecycleMessageFromPayload(null)).toBeNull(); + expect(buildLifecycleMessageFromPayload(undefined)).toBeNull(); + expect(buildLifecycleMessageFromPayload(42)).toBeNull(); + }); + + it('returns null for non-terminal message types', () => { + expect(buildLifecycleMessageFromPayload({ type: 'claude-chunk' })).toBeNull(); + expect(buildLifecycleMessageFromPayload({ type: 'session-created' })).toBeNull(); + }); + + it('builds completed lifecycle for -complete suffix', () => { + const now = Date.now(); + const result = buildLifecycleMessageFromPayload({ + type: 'claude-complete', + sessionId: 'sess-1', + }); + expect(result).toMatchObject({ + type: 'session-state-changed', + provider: 'claude', + sessionId: 'sess-1', + state: 'completed', + reason: 'claude-complete', + }); + expect(result.changedAt).toBeGreaterThanOrEqual(now); + }); + + it('builds completed lifecycle for cursor-result', () => { + const result = buildLifecycleMessageFromPayload({ + type: 'cursor-result', + sessionId: 'cursor-sess', + }); + expect(result.state).toBe('completed'); + expect(result.provider).toBe('cursor'); + }); + + it('builds failed lifecycle for -error suffix', () => { + const result = buildLifecycleMessageFromPayload({ + type: 'codex-error', + sessionId: 'codex-sess', + }); + expect(result).toMatchObject({ + state: 'failed', + provider: 'codex', + reason: 'codex-error', + }); + }); + + it('prefers actualSessionId over sessionId', () => { + const result = buildLifecycleMessageFromPayload({ + type: 'gemini-complete', + sessionId: 'old-id', + actualSessionId: 'real-id', + }); + expect(result.sessionId).toBe('real-id'); + }); + + it('uses fallbackProvider when type prefix is unknown', () => { + const result = buildLifecycleMessageFromPayload( + { type: 'custom-complete', sessionId: 's1' }, + 'openrouter', + ); + expect(result.provider).toBe('openrouter'); + }); + + it('uses payload.provider over fallbackProvider', () => { + const result = buildLifecycleMessageFromPayload( + { type: 'custom-complete', sessionId: 's1', provider: 'nano' }, + 'openrouter', + ); + expect(result.provider).toBe('nano'); + }); + + it('includes projectName from fallbackProjectName', () => { + const result = buildLifecycleMessageFromPayload( + { type: 'claude-complete', sessionId: 's1' }, + null, + 'my-project', + ); + expect(result.projectName).toBe('my-project'); + }); + + it('omits projectName when not resolvable', () => { + const result = buildLifecycleMessageFromPayload( + { type: 'claude-complete', sessionId: 's1' }, + null, + null, + ); + expect(result).not.toHaveProperty('projectName'); + }); + + it('resolves projectName from projectPath via deps', () => { + const deps = { + isKnownPath: () => true, + encodePath: (p) => `encoded-${p}`, + }; + const result = buildLifecycleMessageFromPayload( + { type: 'claude-error', sessionId: 's1', projectPath: '/proj' }, + null, + null, + deps, + ); + expect(result.projectName).toBe('encoded-/proj'); + expect(result.state).toBe('failed'); + }); +}); diff --git a/server/claude-sdk.js b/server/claude-sdk.js index f0faa351..e484149a 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -172,13 +172,14 @@ function mapCliOptionsToSDK(options = {}) { * @param {Array} tempImagePaths - Temp image file paths for cleanup * @param {string} tempDir - Temp directory for cleanup */ -function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) { +function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) { activeSessions.set(sessionId, { instance: queryInstance, startTime: Date.now(), status: 'active', tempImagePaths, - tempDir + tempDir, + writer, }); } @@ -645,7 +646,7 @@ async function queryClaudeSDK(command, options = {}, ws) { // Track the query instance for abort capability if (capturedSessionId) { - addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); + addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws); } // Process streaming messages @@ -657,7 +658,7 @@ async function queryClaudeSDK(command, options = {}, ws) { if (message.session_id && !capturedSessionId) { capturedSessionId = message.session_id; - addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); + addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws); // Set session ID on writer if (ws.setSessionId && typeof ws.setSessionId === 'function') { @@ -681,7 +682,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); @@ -779,6 +781,7 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionId: capturedSessionId, provider: 'claude', mode: sessionMode || 'research', + projectName: sessionProjectPath ? encodeProjectPath(sessionProjectPath) : undefined, }); } @@ -938,12 +941,23 @@ async function runClaudeBtw({ question, transcript, cwd, model, signal }) { } // Export public API +function rebindClaudeSDKSessionWriter(sessionId, newWriter) { + const session = getSession(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + export { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, + rebindClaudeSDKSessionWriter, resolveToolApproval, getContextWindowForModel, runClaudeBtw, 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..8e4f12de 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -711,7 +711,8 @@ export async function spawnGemini(command, options = {}, ws) { startTime: startTimeValue, options, sessionAllowedTools, - sessionDisallowedTools + sessionDisallowedTools, + writer: ws, }; const statusHeartbeat = setInterval(() => { @@ -931,7 +932,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; @@ -1361,3 +1368,13 @@ export function getGeminiSessionStartTime(sessionId) { export function getActiveGeminiSessions() { return Array.from(activeGeminiSessions.keys()); } + +export function rebindGeminiSessionWriter(sessionId, newWriter) { + const session = activeGeminiSessions.get(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} diff --git a/server/index.js b/server/index.js index 1f861604..a3822743 100755 --- a/server/index.js +++ b/server/index.js @@ -42,15 +42,21 @@ 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 { + inferProviderFromMessageType as _inferProviderFromMessageType, + resolveProjectName as _resolveProjectName, + enrichSessionEventPayload as _enrichSessionEventPayload, + buildLifecycleMessageFromPayload as _buildLifecycleMessageFromPayload, +} from './utils/sessionLifecycle.js'; import { getProjectTokenUsageSummary } from './project-token-usage.js'; -import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js'; +import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, rebindClaudeSDKSessionWriter, resolveToolApproval } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getCursorSessionStartTime, getActiveCursorSessions } from './cursor-cli.js'; -import { queryCodex, abortCodexSession, isCodexSessionActive, getCodexSessionStartTime, getActiveCodexSessions } from './openai-codex.js'; -import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getGeminiSessionStartTime, getActiveGeminiSessions } from './gemini-cli.js'; -import { queryOpenRouter, abortOpenRouterSession, isOpenRouterSessionActive, getOpenRouterSessionStartTime, getActiveOpenRouterSessions } from './openrouter.js'; -import { queryLocalGPU, abortLocalGPUSession, isLocalGPUSessionActive, getLocalGPUSessionStartTime, getActiveLocalGPUSessions } from './local-gpu.js'; -import { spawnNanoClaudeCode, abortNanoClaudeCodeSession, isNanoClaudeCodeSessionActive, getNanoClaudeCodeSessionStartTime, getActiveNanoClaudeCodeSessions } from './nano-claude-code.js'; +import { queryCodex, abortCodexSession, isCodexSessionActive, getCodexSessionStartTime, getActiveCodexSessions, rebindCodexSessionWriter } from './openai-codex.js'; +import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getGeminiSessionStartTime, getActiveGeminiSessions, rebindGeminiSessionWriter } from './gemini-cli.js'; +import { queryOpenRouter, abortOpenRouterSession, isOpenRouterSessionActive, getOpenRouterSessionStartTime, rebindOpenRouterSessionWriter } from './openrouter.js'; +import { queryLocalGPU, abortLocalGPUSession, isLocalGPUSessionActive, getLocalGPUSessionStartTime, getActiveLocalGPUSessions, rebindLocalGPUSessionWriter } from './local-gpu.js'; +import { spawnNanoClaudeCode, abortNanoClaudeCodeSession, isNanoClaudeCodeSessionActive, getNanoClaudeCodeSessionStartTime, getActiveNanoClaudeCodeSessions, rebindNanoClaudeCodeSessionWriter } from './nano-claude-code.js'; import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import mcpRoutes from './routes/mcp.js'; @@ -72,7 +78,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'; @@ -1361,6 +1367,73 @@ wss.on('connection', (ws, request) => { } }); +// --- Session lifecycle helpers (delegated to server/utils/sessionLifecycle.js) --- + +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}`); +} + +/** Shared deps object that wires the extracted helpers to real DB + fs. */ +const _lifecycleDeps = { + isKnownPath(projectPath) { + const normalizedPath = path.resolve(projectPath); + if (!isKnownLifecycleProjectPath(normalizedPath)) { + warnUnknownLifecycleProjectPath(normalizedPath); + return false; + } + return true; + }, + encodePath(projectPath) { + const normalizedPath = path.resolve(projectPath); + try { + return encodeProjectPath(normalizedPath); + } catch (error) { + if (DEBUG_SESSION_LIFECYCLE) { + console.debug('[DEBUG] Failed to encode project path for lifecycle payload:', normalizedPath, error?.message || error); + } + throw error; + } + }, +}; + +function resolveProjectName(projectName = null, projectPath = null) { + return _resolveProjectName(projectName, projectPath, _lifecycleDeps); +} + +function enrichSessionEventPayload(payload, fallbackProjectPath = null) { + return _enrichSessionEventPayload(payload, fallbackProjectPath, _lifecycleDeps); +} + +function buildLifecycleMessageFromPayload(payload, fallbackProvider = null, fallbackProjectName = null) { + return _buildLifecycleMessageFromPayload(payload, fallbackProvider, fallbackProjectName, _lifecycleDeps); +} + /** * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface */ @@ -1375,12 +1448,25 @@ 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)); + } } } + replaceSocket(newWs) { + this.ws = newWs; + } + setSessionId(sessionId) { this.sessionId = sessionId; } @@ -1522,10 +1608,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; @@ -1556,8 +1719,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); @@ -1572,6 +1748,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; } @@ -1588,6 +1770,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); }); @@ -1601,6 +1789,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; } @@ -1617,6 +1811,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); }); @@ -1630,6 +1830,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; } @@ -1646,6 +1852,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); }); @@ -1659,6 +1871,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; } @@ -1675,6 +1893,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); }); @@ -1689,6 +1913,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; } @@ -1705,6 +1935,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); }); @@ -1718,6 +1954,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; } @@ -1734,6 +1976,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); }); @@ -1744,9 +1992,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, @@ -1783,6 +2044,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, @@ -1804,6 +2072,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'; @@ -1835,6 +2110,29 @@ function handleChatConnection(ws, request) { startTime = getClaudeSDKSessionStartTime(sessionId); } + // If the session is still running, rebind its writer to the + // current WebSocket so that subsequent messages reach the + // reconnected client instead of the stale (closed) socket. + if (isActive && sessionId) { + let rebound = false; + if (provider === 'codex') { + rebound = rebindCodexSessionWriter(sessionId, writer); + } else if (provider === 'gemini') { + rebound = rebindGeminiSessionWriter(sessionId, writer); + } else if (provider === 'openrouter') { + rebound = rebindOpenRouterSessionWriter(sessionId, writer); + } else if (provider === 'local') { + rebound = rebindLocalGPUSessionWriter(sessionId, writer); + } else if (provider === 'nano') { + rebound = rebindNanoClaudeCodeSessionWriter(sessionId, writer); + } else if (provider !== 'cursor') { + rebound = rebindClaudeSDKSessionWriter(sessionId, writer); + } + if (rebound) { + console.log(`[INFO] Rebound ${provider} session ${sessionId} writer to new WebSocket`); + } + } + writer.send({ type: 'session-status', sessionId, @@ -2731,28 +3029,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/local-gpu.js b/server/local-gpu.js index a94537a3..47ecc1a0 100644 --- a/server/local-gpu.js +++ b/server/local-gpu.js @@ -737,6 +737,7 @@ export async function queryLocalGPU(command, options = {}, ws) { status: 'running', abortController, startTime: Date.now(), + writer: ws, }); const userText = (command || '').replace(/\s*\[Context:[^\]]*\]\s*/gi, '').trim(); @@ -992,6 +993,16 @@ export function getActiveLocalGPUSessions() { .map(([id, s]) => ({ sessionId: id, startTime: s.startTime })); } +export function rebindLocalGPUSessionWriter(sessionId, newWriter) { + const session = activeLocalGPUSessions.get(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + setInterval(() => { const now = Date.now(); for (const [id, session] of activeLocalGPUSessions.entries()) { diff --git a/server/nano-claude-code.js b/server/nano-claude-code.js index 86d51238..d1f51ece 100644 --- a/server/nano-claude-code.js +++ b/server/nano-claude-code.js @@ -174,7 +174,7 @@ export async function spawnNanoClaudeCode(command, options = {}, ws) { env: { ...(env || process.env) }, }); - activeNanoSessions.set(capturedSessionId, { process: child, startTime: Date.now() }); + activeNanoSessions.set(capturedSessionId, { process: child, startTime: Date.now(), writer: ws }); const getSessionStartTime = () => activeNanoSessions.get(capturedSessionId)?.startTime; @@ -325,6 +325,16 @@ export function getActiveNanoClaudeCodeSessions() { return Array.from(activeNanoSessions.keys()); } +export function rebindNanoClaudeCodeSessionWriter(sessionId, newWriter) { + const sessionData = activeNanoSessions.get(sessionId); + if (!sessionData || !sessionData.writer) return false; + if (typeof sessionData.writer.replaceSocket === 'function') { + sessionData.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + /** Kill all in-flight Nano CLI children (e.g. before process exit). */ export function killAllNanoClaudeCodeChildren() { for (const { process: childProc } of activeNanoSessions.values()) { diff --git a/server/openai-codex.js b/server/openai-codex.js index 1fa818c8..3a2a477b 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -24,6 +24,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'; import { CODEX_MODELS } from '../shared/modelConstants.js'; import { BTW_SYSTEM_PROMPT, buildBtwUserMessage } from './utils/btw.js'; @@ -414,7 +415,8 @@ export async function queryCodex(command, options = {}, ws) { codex, status: 'running', abortController, - startTime: Date.now() + startTime: Date.now(), + writer: ws, }); const publishSessionId = (resolvedSessionId) => { @@ -440,12 +442,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); @@ -696,6 +697,16 @@ export function getActiveCodexSessions() { return sessions; } +export function rebindCodexSessionWriter(sessionId, newWriter) { + const session = activeCodexSessions.get(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + /** * Helper to send message via WebSocket or writer * @param {WebSocket|object} ws - WebSocket or response writer diff --git a/server/openrouter.js b/server/openrouter.js index 555c7823..b1b036e2 100644 --- a/server/openrouter.js +++ b/server/openrouter.js @@ -627,6 +627,7 @@ export async function queryOpenRouter(command, options = {}, ws) { status: 'running', abortController, startTime: Date.now(), + writer: ws, }); // Strip [Context: ...] prefixes to extract the user's actual text for the display name @@ -894,6 +895,16 @@ export function getActiveOpenRouterSessions() { .map(([id, s]) => ({ sessionId: id, startTime: s.startTime })); } +export function rebindOpenRouterSessionWriter(sessionId, newWriter) { + const session = activeOpenRouterSessions.get(sessionId); + if (!session || !session.writer) return false; + if (typeof session.writer.replaceSocket === 'function') { + session.writer.replaceSocket(newWriter.ws || newWriter); + return true; + } + return false; +} + // Periodic cleanup (mirrors Codex pattern) setInterval(() => { const now = Date.now(); diff --git a/server/projects.js b/server/projects.js index 776e0ff7..d8d31671 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,40 @@ 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; + } + + return null; +} + function getDeletedProjectsStore(config) { if (!config[DELETED_PROJECTS_CONFIG_KEY] || typeof config[DELETED_PROJECTS_CONFIG_KEY] !== 'object') { config[DELETED_PROJECTS_CONFIG_KEY] = {}; @@ -143,8 +257,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 +331,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 +406,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 +508,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 +650,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 +737,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 +989,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 +1664,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 +2811,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 +2914,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 +2969,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 +3608,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 +3829,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 +4088,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 +4381,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 +4394,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 +4419,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 +4431,8 @@ async function deleteCodexSession(sessionId) { if (deletedIndex) { sessionDb.deleteSession(sessionId); + clearCachedCodexSessionFilePath(normalizedSessionId); + invalidateCodexSessionsIndexCache(); } if (deletedFile || deletedIndex) { @@ -4351,6 +4630,7 @@ export { getSessions, getSessionMessages, collectCodexProjectCandidates, + resolveValidProjectOwnerUserId, parseJsonlSessions, renameProject, renameSession, @@ -4367,6 +4647,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/server/utils/sessionLifecycle.js b/server/utils/sessionLifecycle.js new file mode 100644 index 00000000..069bd7b9 --- /dev/null +++ b/server/utils/sessionLifecycle.js @@ -0,0 +1,148 @@ +/** + * Session lifecycle helpers — extracted from server/index.js for testability. + * + * These pure-ish functions handle: + * - provider inference from message type prefixes + * - project-name resolution (with optional filesystem + DB validation) + * - session-event payload enrichment + * - lifecycle message construction from completion/error payloads + */ + +/** + * Infer the canonical provider name from a message-type prefix. + * Falls back to `fallbackProvider` when no prefix matches. + */ +export 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; +} + +/** + * Resolve a human-readable project name from either an explicit name or a + * filesystem path. When `isKnownPath` and `encodePath` callbacks are + * provided the function validates the path before encoding; otherwise it + * performs a simple passthrough (useful in unit tests that don't need a + * real filesystem). + * + * @param {string|null} projectName - explicit project name (returned as-is when non-empty) + * @param {string|null} projectPath - filesystem path to resolve from + * @param {object} [deps] - optional dependency overrides for testing + * @param {function} [deps.isKnownPath] - (path) => boolean + * @param {function} [deps.encodePath] - (path) => string + */ +export function resolveProjectName( + projectName = null, + projectPath = null, + deps = {}, +) { + if (typeof projectName === 'string' && projectName.trim().length > 0) { + return projectName; + } + + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return null; + } + + const { isKnownPath, encodePath } = deps; + + // When no validators are injected we cannot resolve from path alone. + if (typeof isKnownPath !== 'function' || typeof encodePath !== 'function') { + return null; + } + + if (!isKnownPath(projectPath)) { + return null; + } + + try { + return encodePath(projectPath); + } catch { + return null; + } +} + +/** + * Enrich a session-event payload with a resolved `projectName` when the + * original payload is missing one. + */ +export function enrichSessionEventPayload(payload, fallbackProjectPath = null, deps = {}) { + 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, + deps, + ); + if (!resolvedProjectName || payload.projectName === resolvedProjectName) { + return payload; + } + + return { + ...payload, + projectName: resolvedProjectName, + }; +} + +/** + * Build a normalised `session-state-changed` lifecycle message from a + * provider completion or error payload. + * + * Returns `null` when the payload does not represent a terminal state. + */ +export function buildLifecycleMessageFromPayload( + payload, + fallbackProvider = null, + fallbackProjectName = null, + deps = {}, +) { + 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, + deps, + ); + + return { + type: 'session-state-changed', + provider, + sessionId: payload.actualSessionId || payload.sessionId || null, + state, + reason: messageType, + changedAt: Date.now(), + ...(projectName ? { projectName } : {}), + }; +} diff --git a/src/components/chat/hooks/__tests__/useChatSessionState.test.ts b/src/components/chat/hooks/__tests__/useChatSessionState.test.ts new file mode 100644 index 00000000..dff147dc --- /dev/null +++ b/src/components/chat/hooks/__tests__/useChatSessionState.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { + hasPendingOptimisticSessionState, + hasTemporaryProcessingSessionKeys, +} from '../useChatSessionState'; + +describe('useChatSessionState temporary session helpers', () => { + it('treats temp sessions as pending optimistic sessions', () => { + expect(hasPendingOptimisticSessionState(null, 'new-session-123')).toBe(true); + expect(hasPendingOptimisticSessionState(null, 'temp-123')).toBe(true); + expect(hasPendingOptimisticSessionState({ sessionId: null, startedAt: Date.now() }, null)).toBe(true); + expect(hasPendingOptimisticSessionState(null, 'sess-123')).toBe(false); + }); + + it('detects temporary processing keys for both raw and scoped temp ids', () => { + expect(hasTemporaryProcessingSessionKeys(new Set(['new-session-1']))).toBe(true); + expect(hasTemporaryProcessingSessionKeys(new Set(['temp-1']))).toBe(true); + expect(hasTemporaryProcessingSessionKeys(new Set(['proj-a::codex::new-session-2']))).toBe(true); + expect(hasTemporaryProcessingSessionKeys(new Set(['proj-a::codex::temp-2']))).toBe(true); + expect(hasTemporaryProcessingSessionKeys(new Set(['proj-a::codex::sess-2']))).toBe(false); + expect(hasTemporaryProcessingSessionKeys(new Set(['sess-2']))).toBe(false); + }); +}); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index aadd0157..c4a0faa7 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,25 +8,44 @@ 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'; +} 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 { applyEditedMessageToHistory, createChatMessageId } from '../utils/chatMessages'; -import { clearSessionTimerStart, getProviderSettingsKey, persistSessionTimerStart, safeLocalStorage } from '../utils/chatStorage'; +import { + buildDraftInputStorageKey, + clearScopedPendingSessionId, + clearScopedProviderSessionId, + clearSessionTimerStart, + getProviderSettingsKey, + persistScopedPendingSessionId, + persistScopedProviderSessionId, + persistSessionTimerStart, + readScopedPendingSessionId, + readScopedProviderSessionId, + safeLocalStorage, +} from "../utils/chatStorage"; import { hasUnsavedComposerDraft, normalizeProgrammaticDraft, resolveLineHeightPx } from '../utils/composerUtils'; -import { consumeWorkspaceQaDraft, WORKSPACE_QA_DRAFT_EVENT } from '../../../utils/workspaceQa'; -import { consumeReferenceChatDraft, REFERENCE_CHAT_DRAFT_EVENT } from '../../../utils/referenceChatDraft'; +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'; import type { AttachedPrompt, @@ -35,29 +54,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; @@ -77,19 +121,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. */ getChatMessagesForBtw?: () => ChatMessage[]; @@ -101,7 +164,7 @@ interface MentionableFile { } interface CommandExecutionResult { - type: 'builtin' | 'custom'; + type: "builtin" | "custom"; action?: string; data?: any; content?: string; @@ -122,28 +185,39 @@ interface ProgrammaticMessageDraft { } 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}`; @@ -151,49 +225,60 @@ 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-") + || sessionId.startsWith("temp-") + ), + ); const BTW_TRANSCRIPT_MAX_CHARS = 120_000; @@ -224,7 +309,7 @@ function buildBtwTranscript(messages: ChatMessage[]): string { } const getRouteSessionId = () => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return null; } @@ -240,6 +325,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, @@ -260,9 +439,11 @@ export function useChatComposerState({ sendMessage, sendByCtrlEnter, onSessionActive, + onSessionProcessing, onInputFocusChange, onFileOpen, onShowSettings, + processingSessions, pendingViewSessionRef, scrollToBottom, setChatMessages, @@ -272,54 +453,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); @@ -329,12 +522,27 @@ 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); // Programmatic draft loads and async submit callbacks must read the latest composer state // without waiting for a rerender, so the mutable refs intentionally mirror state here. @@ -343,8 +551,90 @@ export function useChatComposerState({ const attachedPromptRef = useRef(null); const pendingStageTagKeysRef = useRef([]); 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 textareaLayoutTimeoutRef = useRef | null>(null); const pendingEditedMessageIdRef = useRef(null); + 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 () => { @@ -352,6 +642,19 @@ 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(); if (textareaLayoutTimeoutRef.current) { clearTimeout(textareaLayoutTimeoutRef.current); textareaLayoutTimeoutRef.current = null; @@ -359,6 +662,53 @@ export function useChatComposerState({ }; }, []); + 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([]); pendingEditedMessageIdRef.current = null; @@ -377,24 +727,24 @@ export function useChatComposerState({ }, [pendingStageTagKeys]); 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]); @@ -402,58 +752,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(), }, ]); @@ -461,8 +815,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(), }, ]); @@ -472,17 +826,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(), }, ]); @@ -491,8 +845,8 @@ export function useChatComposerState({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `⏪ ${data.message}`, + type: "assistant", + content: `閳?${data.message}`, timestamp: Date.now(), }, ]); @@ -500,43 +854,46 @@ 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 executeCommand = useCallback( async (command: SlashCommand, rawInput?: string) => { @@ -546,9 +903,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, @@ -556,26 +917,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, @@ -589,7 +942,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. } @@ -689,20 +1043,21 @@ export function useChatComposerState({ } 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(), }, @@ -888,8 +1243,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; } @@ -898,7 +1253,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; @@ -907,7 +1265,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; @@ -915,7 +1276,7 @@ export function useChatComposerState({ validFiles.push(file); } catch (error) { - console.error('Error validating file:', error, file); + console.error("Error validating file:", error, file); } }); @@ -932,7 +1293,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); } }); @@ -984,7 +1347,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(); @@ -1020,17 +1383,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(); @@ -1047,54 +1413,692 @@ 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; - const currentAttachedFiles = attachedFilesRef.current; - const currentAttachedPrompt = attachedPromptRef.current; - const currentStageTagKeys = pendingStageTagKeysRef.current; + if ( + (!currentInput.trim() && + attachedFiles.length === 0 && + !attachedPrompt) || + !selectedProject + ) { + return; + } - if ((!currentInput.trim() && currentAttachedFiles.length === 0 && !currentAttachedPrompt) || isLoading || !selectedProject) { + if (isLoading && provider !== "codex") { return; } + const currentAttachedFiles = attachedFilesRef.current; + const currentAttachedPrompt = attachedPromptRef.current; + 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); attachedPromptRef.current = null; pendingEditedMessageIdRef.current = null; @@ -1107,7 +2111,7 @@ export function useChatComposerState({ resetCommandMenuState(); setIsTextareaExpanded(false); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; } return; } @@ -1119,8 +2123,9 @@ 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; @@ -1133,22 +2138,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(), }, @@ -1157,6 +2158,85 @@ export function useChatComposerState({ setIntakeGreeting(null); } + const { + effectiveSessionId, + isNewSession, + sessionToActivate, + resolvedProvider, + resolvedProjectPath, + } = resolveSessionContext(); + const isCodexSessionBusy = + resolvedProvider === "codex" && + hasProcessingSession( + sessionToActivate, + resolvedProvider, + selectedProject?.name || currentProjectName, + ); + 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: | { @@ -1172,12 +2252,13 @@ export function useChatComposerState({ try { uploadedFiles = await uploadFilesToProject(currentAttachedFiles); } 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(), }, @@ -1187,7 +2268,10 @@ export function useChatComposerState({ messageAttachments = currentAttachedFiles.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, @@ -1200,11 +2284,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: { @@ -1216,7 +2300,9 @@ export function useChatComposerState({ ) => { const sourceFile = currentAttachedFiles[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; @@ -1242,7 +2328,7 @@ export function useChatComposerState({ try { uploadedImages = await uploadPreviewImages(imageFiles); } catch (error) { - console.error('Image preview upload failed:', error); + console.error("Image preview upload failed:", error); } } } @@ -1251,11 +2337,12 @@ export function useChatComposerState({ const userMessageId = createChatMessageId(); const userMessage: ChatMessage = { messageId: userMessageId, - type: 'user', + type: "user", content: normalizedInput, submittedContent: messageContent, images: uploadedImages.length > 0 ? uploadedImages : undefined, - attachments: messageAttachments.length > 0 ? messageAttachments : undefined, + attachments: + messageAttachments.length > 0 ? messageAttachments : undefined, timestamp: new Date(), ...(editedMessageId ? { editedFromMessageId: editedMessageId } : {}), ...(currentAttachedPrompt ? { attachedPrompt: currentAttachedPrompt } : {}), @@ -1271,7 +2358,7 @@ export function useChatComposerState({ setIsLoading(true); setCanAbortSession(true); setClaudeStatus({ - text: 'Processing', + text: "Processing", tokens: 0, can_interrupt: true, startTime: turnStartTime, @@ -1280,81 +2367,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: { @@ -1367,14 +2450,13 @@ export function useChatComposerState({ toolsSettings, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, - stageTagKeys: currentStageTagKeys, - stageTagSource: 'task_context', + stageTagKeys: pendingStageTagKeys, + 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: { @@ -1383,20 +2465,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: currentStageTagKeys, - stageTagSource: 'task_context', + stageTagKeys: pendingStageTagKeys, + 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: { @@ -1405,20 +2486,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: currentStageTagKeys, - stageTagSource: 'task_context', + stageTagKeys: pendingStageTagKeys, + 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: { @@ -1427,18 +2511,17 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: openrouterModel, - permissionMode: effectivePermissionMode, + permissionMode, toolsSettings, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, - stageTagKeys: currentStageTagKeys, - stageTagSource: 'task_context', + stageTagKeys: pendingStageTagKeys, + 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: { @@ -1447,20 +2530,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: { @@ -1472,14 +2556,13 @@ export function useChatComposerState({ toolsSettings, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, - stageTagKeys: currentStageTagKeys, - stageTagSource: 'task_context', + stageTagKeys: pendingStageTagKeys, + stageTagSource: "task_context", }, }); } else { - console.log('[DEBUG] Sending claude-command'); sendMessage({ - type: 'claude-command', + type: "claude-command", command: messageContent, options: { projectPath: resolvedProjectPath, @@ -1487,19 +2570,19 @@ 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: currentStageTagKeys, - stageTagSource: 'task_context', + stageTagKeys: pendingStageTagKeys, + stageTagSource: "task_context", }, }); } - setInput(''); - inputValueRef.current = ''; + setInput(""); + inputValueRef.current = ""; setPendingStageTagKeys([]); pendingStageTagKeysRef.current = []; resetCommandMenuState(); @@ -1508,16 +2591,19 @@ export function useChatComposerState({ setUploadingFiles(new Map()); setFileErrors(new Map()); setIsTextareaExpanded(false); - setThinkingMode('none'); + setThinkingMode("none"); setAttachedPrompt(null); + setSteerMode(false); attachedPromptRef.current = null; pendingEditedMessageIdRef.current = null; 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, @@ -1530,18 +2616,25 @@ 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, @@ -1549,14 +2642,13 @@ export function useChatComposerState({ setIsLoading, setIsUserScrolledUp, slashCommands, - thinkingMode, + steerMode, t, - intakeGreeting, + thinkingMode, uploadFilesToProject, uploadPreviewImages, ], ); - useEffect(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]); @@ -1566,16 +2658,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) { @@ -1589,11 +2681,6 @@ export function useChatComposerState({ }; const applyQueuedDraft = () => { - const skillDraft = consumeSkillCommandDraft(); - if (skillDraft) { - applyDraft(skillDraft); - return; - } const wqDraft = consumeWorkspaceQaDraft(selectedProject.name); if (wqDraft) { applyDraft(wqDraft); @@ -1606,14 +2693,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 } })(); } @@ -1632,31 +2723,29 @@ 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 computedStyle = window.getComputedStyle(textareaRef.current); const lineHeight = resolveLineHeightPx(computedStyle.lineHeight, computedStyle.fontSize); @@ -1668,7 +2757,7 @@ export function useChatComposerState({ if (!textareaRef.current || input.trim()) { return; } - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; setIsTextareaExpanded(false); }, [input]); @@ -1683,7 +2772,7 @@ export function useChatComposerState({ if (!newValue.trim()) { setPendingStageTagKeys([]); - event.target.style.height = 'auto'; + event.target.style.height = "auto"; setIsTextareaExpanded(false); resetCommandMenuState(); return; @@ -1694,6 +2783,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)) { @@ -1704,21 +2821,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); } @@ -1729,6 +2854,8 @@ export function useChatComposerState({ handleCommandMenuKeyDown, handleFileMentionsKeyDown, handleSubmit, + isCodexQueueShortcutActive, + provider, sendByCtrlEnter, showCommandMenu, showFileDropdown, @@ -1745,7 +2872,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); @@ -1758,8 +2885,8 @@ export function useChatComposerState({ ); const handleClearInput = useCallback(() => { - setInput(''); - inputValueRef.current = ''; + setInput(""); + inputValueRef.current = ""; setPendingStageTagKeys([]); pendingStageTagKeysRef.current = []; setAttachedFiles([]); @@ -1771,7 +2898,7 @@ export function useChatComposerState({ pendingEditedMessageIdRef.current = null; resetCommandMenuState(); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.focus(); } setIsTextareaExpanded(false); @@ -1794,38 +2921,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, }); @@ -1840,7 +2991,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()) { @@ -1858,7 +3024,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); @@ -1869,7 +3035,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); @@ -1879,7 +3050,7 @@ export function useChatComposerState({ validIds.forEach((requestId) => { sendMessage({ - type: 'claude-permission-response', + type: "claude-permission-response", requestId, allow: Boolean(decision?.allow), updatedInput: decision?.updatedInput, @@ -1889,12 +3060,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; } @@ -1904,14 +3079,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); @@ -1932,6 +3114,8 @@ export function useChatComposerState({ textareaRef, inputHighlightRef, isTextareaExpanded, + steerMode, + setSteerMode, thinkingMode, setThinkingMode, codexReasoningEffort, @@ -1976,11 +3160,22 @@ export function useChatComposerState({ isInputFocused, intakeGreeting, setIntakeGreeting, - setPendingStageTagKeys, - submitProgrammaticInput, btwOverlay, closeBtwOverlay, + setPendingStageTagKeys, + submitProgrammaticInput, submitProgrammaticMessage, loadMessageIntoComposer, + 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 99b4a0d6..7bd248ee 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,23 +1,41 @@ -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'; +} from "../utils/chatStorage"; +import { + emitSessionFilterDebugLog, + syncSessionFilterDebugSetting, +} from "../utils/sessionFilterDebug"; import { invalidateSessionMessageCache } from './useChatSessionState'; -import { RESUMING_STATUS_TEXT } from '../types/types'; -import i18n from '../../../i18n/config'; -import type { ChatMessage, PendingPermissionRequest } from '../types/types'; -import type { Project, ProjectSession, SessionNavigationSource, SessionProvider } from '../../../types/app'; +import { RESUMING_STATUS_TEXT } from "../types/types"; +import i18n from "../../../i18n/config"; +import type { ChatMessage, PendingPermissionRequest } from "../types/types"; +import type { + Project, + ProjectSession, + SessionNavigationSource, + SessionProvider, +} from "../../../types/app"; +import { isProviderAllowed, normalizeProvider } from "../../../utils/providerPolicy"; type PendingViewSession = { sessionId: string | null; @@ -40,6 +58,8 @@ type LatestChatMessage = { [key: string]: any; }; +const warnedUnknownProviders = new Set(); + interface UseChatRealtimeHandlersArgs { latestMessage: LatestChatMessage | null; provider: SessionProvider; @@ -50,25 +70,65 @@ 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, options?: { source?: SessionNavigationSource }, ) => void; + sendMessage?: (message: Record) => void; } type FinalizeSessionLifecycleOptions = { @@ -121,15 +181,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; }); @@ -137,14 +207,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, @@ -161,12 +238,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({ @@ -191,13 +271,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); @@ -207,11 +299,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, @@ -221,12 +313,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({ @@ -242,10 +334,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, @@ -260,7 +352,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())); @@ -273,7 +365,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); @@ -281,7 +375,8 @@ export function useChatRealtimeHandlers({ ...message, subagentState: { childTools: [...existingChildren, ...newChildren], - currentToolIndex: existingChildren.length + newChildren.length - 1, + currentToolIndex: + existingChildren.length + newChildren.length - 1, isComplete: false, }, }; @@ -309,23 +404,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, @@ -341,20 +441,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, @@ -375,8 +481,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' @@ -406,76 +515,363 @@ 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 shouldRebindTemporarySession = + Boolean( + temporaryActiveSessionId && + (inferredMessageProvider === "codex" || inferredMessageProvider === "gemini") && + routedMessageSessionId && + routedMessageSessionId !== temporaryActiveSessionId, + ) && !selectedSession?.id; + + if ( + shouldRebindTemporarySession && + temporaryActiveSessionId && + routedMessageSessionId + ) { + if (inferredMessageProvider === "codex") { + onCodexSessionIdResolved?.( + temporaryActiveSessionId, + routedMessageSessionId, + ); + } + onReplaceTemporarySession?.( + routedMessageSessionId, + inferredMessageProvider as "codex" | "gemini", + 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) || + shouldRebindTemporarySession; 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), + shouldRebindTemporarySession, + 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); + notifySessionCompleted(sessionId, latestMessageProvider, latestMessageProjectName); finalizeSessionLifecycle([sessionId], { projectName: selectedProject?.name, onSessionInactive, @@ -484,12 +880,34 @@ export function useChatRealtimeHandlers({ }); }; - const persistStartTime = (startTime?: number | null, ...sessionIds: Array) => { + 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 + ) => { 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; } @@ -497,7 +915,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; } @@ -507,7 +928,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, })); }; @@ -525,12 +947,25 @@ 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 markSessionsAsCompleted = ( + ...sessionIds: Array + ) => { + const normalizedSessionIds = sessionIds.filter( + (id): id is string => typeof id === "string" && id.length > 0, + ); + normalizedSessionIds.forEach((sessionId) => { + clearSessionTimerStart(sessionId); + notifySessionCompleted( + sessionId, + latestMessageProvider, + latestMessageProjectName, + ); + }); finalizeSessionLifecycle(sessionIds, { projectName: selectedProject?.name, onSessionInactive, @@ -541,76 +976,361 @@ export function useChatRealtimeHandlers({ 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 (latestMessage.sessionId !== activeViewSessionId) { - if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { - handleBackgroundLifecycle(latestMessage.sessionId); + 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 ( + 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, { source: 'system' }); + onReplaceTemporarySession?.( + latestMessage.sessionId, + createdSessionProvider, + createdProjectName, + temporarySessionId, + ); + if (createdProjectName || pendingViewSessionRef.current) { + onNavigateToSession?.(latestMessage.sessionId, createdSessionProvider, createdProjectName || undefined, { source: 'system' }); + } 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); @@ -618,54 +1338,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 (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, { source: 'system' }); 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); @@ -673,57 +1426,83 @@ 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, { source: 'system' }); 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; @@ -731,7 +1510,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); @@ -739,28 +1518,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, @@ -772,12 +1555,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: { @@ -797,14 +1583,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); @@ -813,30 +1601,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; } @@ -848,28 +1657,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, @@ -880,27 +1706,35 @@ 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, { source: 'system' }); } } } 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, @@ -909,127 +1743,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, @@ -1039,7 +1936,7 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', + type: "assistant", content, timestamp: new Date(), }, @@ -1048,14 +1945,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); } @@ -1065,7 +1962,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( @@ -1075,10 +1972,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; @@ -1087,16 +1987,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, }, @@ -1107,16 +2010,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, }, @@ -1125,13 +2031,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, @@ -1151,11 +2060,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}`, @@ -1169,16 +2078,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, }, ]); @@ -1186,14 +2097,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, @@ -1209,8 +2123,8 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, toolName: `${codexData.server}:${codexData.tool}`, @@ -1224,8 +2138,8 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, toolName: `${codexData.server}:${codexData.tool}`, @@ -1238,9 +2152,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( @@ -1253,11 +2167,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, @@ -1268,11 +2182,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, @@ -1282,12 +2196,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(), }, @@ -1296,97 +2210,222 @@ 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, { source: 'system' }); } - 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; @@ -1398,43 +2437,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; @@ -1444,9 +2498,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 5b453bd6..2a8ad8ce 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -5,14 +5,40 @@ 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 { hydrateStoredChatMessages } from '../utils/chatMessages'; +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, + isSessionScopeKeyTemporary, + isTemporarySessionId, + parseSessionScopeKey, + scopeKeyMatchesSessionId, +} from '../../../utils/sessionScope'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; @@ -39,12 +65,11 @@ export function invalidateSessionMessageCache(projectName: string, sessionId: st } /** Grace period for WebSocket status-check response before clearing stale resume state */ -const STATUS_VALIDATION_TIMEOUT_MS = 5000; - +const STATUS_VALIDATION_TIMEOUT_MS = 10_000; +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) { @@ -64,6 +89,73 @@ function resolveSessionProviderForLoad(session: ProjectSession | null, project: return 'claude'; } +function readStoredChatMessages( + projectName: string, + sessionId: string, + provider: Provider | string | null | undefined, + options: { + allowLegacyFallback?: boolean; + } = {}, +): ChatMessage[] { + const candidateKeys = buildSessionMessageCacheCandidateKeys( + projectName, + sessionId, + provider, + options, + ); + + 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; +} + +export function hasPendingOptimisticSessionState( + pendingViewSessionRefValue: PendingViewSession | null | undefined, + currentSessionId: string | null | undefined, +): boolean { + return Boolean(pendingViewSessionRefValue) || isTemporarySessionId(currentSessionId); +} + +export function hasTemporaryProcessingSessionKeys( + processingSessions: Set | null | undefined, +): boolean { + if (!processingSessions) { + return false; + } + + return Array.from(processingSessions).some((sessionKey) => ( + isSessionScopeKeyTemporary(sessionKey) || isTemporarySessionId(sessionKey) + )); +} + type PendingViewSession = { sessionId: string | null; startedAt: number; @@ -72,6 +164,7 @@ type PendingViewSession = { interface UseChatSessionStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; + activeProvider?: Provider | null; ws: WebSocket | null; sendMessage: (message: unknown) => void; autoScrollToBottom?: boolean; @@ -86,9 +179,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, @@ -100,32 +224,132 @@ 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 hydrateStoredChatMessages(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 providerHint = selectedSession.__provider + || (activeProvider as Provider | undefined) + || (window.localStorage.getItem('selected-provider') as Provider | null); + const inferredProvider = providerHint + || resolveSessionProviderForLoad(selectedSession, selectedProject); + const allowLegacyFallback = !providerHint; + return readStoredChatMessages( + selectedProject.name, + selectedSession.id, + normalizeProvider(inferredProvider || DEFAULT_PROVIDER), + { allowLegacyFallback }, + ); } return []; }); + const generatedMessageIdMapRef = useRef>(new Map()); + const setChatMessages = useCallback((updater: React.SetStateAction) => { _setChatMessages((prev) => { const next = typeof updater === 'function' ? updater(prev) : updater; - return hydrateStoredChatMessages(next); + 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: nextId }; + } + return msg; + }); + return hasChanges ? final : next; }); }, []); + 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; @@ -134,6 +358,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); @@ -165,22 +390,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; @@ -198,14 +487,24 @@ 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; const cacheKey = `${projectName}:${sessionId}`; if (isInitialLoad) { + initialLoadCountRef.current += 1; const cached = getCachedSessionMessages(cacheKey); if (cached) { if (cached.tokenUsage) setTokenBudget(cached.tokenUsage); @@ -217,6 +516,7 @@ export function useChatSessionState({ } setIsLoadingSessionMessages(true); } else { + moreLoadCountRef.current += 1; setIsLoadingMoreMessages(true); } @@ -234,7 +534,6 @@ export function useChatSessionState({ } const data = await response.json(); - console.log('[DEBUG] Received session messages data:', data); if (isInitialLoad && data.tokenUsage) { setTokenBudget(data.tokenUsage); } @@ -263,9 +562,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); } } }, @@ -277,6 +578,7 @@ export function useChatSessionState({ return [] as ChatMessage[]; } + initialLoadCountRef.current += 1; setIsLoadingSessionMessages(true); try { const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`; @@ -292,7 +594,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); } }, []); @@ -336,7 +639,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; } @@ -361,7 +664,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; @@ -369,7 +682,7 @@ export function useChatSessionState({ isLoadingMoreRef.current = false; } }, - [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession], + [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, rememberSessionSnapshot, selectedProject, selectedSession], ); const handleScroll = useCallback(async () => { @@ -378,18 +691,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; @@ -413,14 +735,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(() => { @@ -440,18 +769,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); } @@ -471,13 +837,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) { @@ -491,13 +861,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); } @@ -511,44 +899,123 @@ 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 = + hasPendingOptimisticSessionState( + pendingViewSessionRef.current, + currentSessionId, + ); + const pendingOptimisticSessionId = + pendingViewSessionId || currentSessionId || null; + const hasPendingProcessing = + pendingOptimisticSessionId + ? hasProcessingSession( + pendingOptimisticSessionId, + selectedSession?.__provider || DEFAULT_PROVIDER, + selectedProject?.name || null, + ) + : hasTemporaryProcessingSessionKeys(processingSessions); + 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, @@ -560,15 +1027,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; } @@ -578,7 +1081,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) { @@ -590,12 +1122,17 @@ export function useChatSessionState({ }; reloadExternalMessages(); + return () => { + cancelled = true; + }; }, [ autoScrollToBottom, externalMessageUpdate, isNearBottom, loadCursorSessionMessages, loadSessionMessages, + rememberSessionSnapshot, + resolvePreferredLoadProvider, scrollToBottom, selectedProject, selectedSession, @@ -608,24 +1145,58 @@ 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-')) { + if (!selectedProject || !selectedSession?.id || isTemporarySessionId(selectedSession.id)) { setTokenBudget(null); return; } - const sessionProvider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider | string; + const sessionProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); if (sessionProvider === 'cursor') { setTokenBudget(null); return; @@ -647,7 +1218,7 @@ export function useChatSessionState({ }; fetchInitialTokenUsage(); - }, [selectedProject, selectedSession]); + }, [resolvePreferredLoadProvider, selectedProject, selectedSession]); const visibleMessages = useMemo(() => { if (chatMessages.length <= visibleMessageCount) { @@ -725,7 +1296,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; @@ -734,7 +1310,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; @@ -742,13 +1328,33 @@ export function useChatSessionState({ return; } + // Don't start the timeout until the WebSocket is connected — the + // check-session-status message can't be delivered until then, so + // timing out before the server even receives the query is premature. + if (!ws) { + return; + } + 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; } @@ -767,7 +1373,17 @@ export function useChatSessionState({ return () => { clearTimeout(timeoutId); }; - }, [currentSessionId, pendingStatusValidationSessionId, processingSessions, selectedSession?.id]); + }, [ + currentSessionId, + hasProcessingSession, + pendingStatusValidationSessionId, + resolvePreferredLoadProvider, + selectedProject, + selectedProject?.name, + selectedSession?.id, + selectedSession?.__provider, + ws, + ]); // Show "Load all" overlay after a batch finishes loading, persist for 2s then hide const prevLoadingRef = useRef(false); @@ -794,7 +1410,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); @@ -845,6 +1461,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); @@ -867,7 +1490,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); diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 77653263..13ad46a0 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -136,6 +136,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; @@ -143,12 +158,33 @@ export interface ChatInterfaceProps { sendMessage: (message: unknown) => void; latestMessage: any; 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, @@ -163,13 +199,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..1b1f23ab --- /dev/null +++ b/src/components/chat/utils/__tests__/codexQueue.test.ts @@ -0,0 +1,297 @@ +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); + }); + + it("preserves order under concurrent temp→settled promotions from multiple temp sessions", () => { + const settledId = "session-settled"; + const tempA = "new-session-aaa"; + const tempB = "new-session-bbb"; + + const initialQueue: SessionQueueMap = { + [settledId]: [ + buildQueuedTurn({ + id: "settled-1", + sessionId: settledId, + text: "settled turn", + kind: "normal", + }), + ], + [tempA]: [ + buildQueuedTurn({ + id: "tempA-1", + sessionId: tempA, + text: "temp A first", + kind: "normal", + }), + buildQueuedTurn({ + id: "tempA-2", + sessionId: tempA, + text: "temp A second", + kind: "steer", + }), + ], + [tempB]: [ + buildQueuedTurn({ + id: "tempB-1", + sessionId: tempB, + text: "temp B first", + kind: "normal", + }), + ], + }; + + // Simulate two sequential temp→settled promotions (the order they arrive) + const afterA = reconcileSettledSessionQueue(initialQueue, settledId, tempA); + const afterBoth = reconcileSettledSessionQueue(afterA, settledId, tempB); + + // tempA and tempB queues should be gone + expect(afterBoth[tempA]).toBeUndefined(); + expect(afterBoth[tempB]).toBeUndefined(); + + // Settled queue should contain all turns in order: + // existing settled → tempA turns → tempB turns + const ids = afterBoth[settledId].map((t) => t.id); + expect(ids).toEqual([ + "settled-1", + "tempA-1", + "tempA-2", + "tempB-1", + ]); + + // All turns should have the settled sessionId + expect( + afterBoth[settledId].every((t) => t.sessionId === settledId), + ).toBe(true); + + // Steer kind should be preserved through reconciliation + const steerTurn = afterBoth[settledId].find((t) => t.id === "tempA-2"); + expect(steerTurn?.kind).toBe("steer"); + }); + + it("preserves order when promotion and reconciliation interleave", () => { + const tempId = "new-session-xyz"; + const settledId = "session-final"; + + let queue: SessionQueueMap = { + [tempId]: [ + buildQueuedTurn({ + id: "t-1", + sessionId: tempId, + text: "first", + kind: "normal", + }), + buildQueuedTurn({ + id: "t-2", + sessionId: tempId, + text: "second", + kind: "normal", + }), + buildQueuedTurn({ + id: "t-3", + sessionId: tempId, + text: "third", + kind: "normal", + }), + ], + }; + + // Promote t-3 to steer (moves to front) before reconciliation + queue = promoteQueuedTurnToSteer(queue, tempId, "t-3"); + expect(queue[tempId].map((t) => t.id)).toEqual(["t-3", "t-1", "t-2"]); + + // Now reconcile temp → settled + queue = reconcileSettledSessionQueue(queue, settledId, tempId); + + expect(queue[tempId]).toBeUndefined(); + const ids = queue[settledId].map((t) => t.id); + // Promoted steer turn stays at front, then remaining in original order + expect(ids).toEqual(["t-3", "t-1", "t-2"]); + expect(queue[settledId][0].kind).toBe("steer"); + expect(queue[settledId].every((t) => t.sessionId === settledId)).toBe(true); + }); +}); 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..2983f394 --- /dev/null +++ b/src/components/chat/utils/__tests__/sessionMessageCache.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSessionMessageCacheCandidateKeys } from '../sessionMessageCache'; + +describe('sessionMessageCache', () => { + it('returns only provider/session scoped key by default', () => { + const keys = buildSessionMessageCacheCandidateKeys('proj-a', 'sess-1', 'codex'); + + expect(keys).toEqual(['chat_messages_proj-a_codex_sess-1']); + }); + + it('includes migration fallback keys only when explicitly requested', () => { + const keys = buildSessionMessageCacheCandidateKeys('proj-a', 'sess-1', 'codex', { + allowLegacyFallback: true, + }); + + 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('deduplicates keys when provider already resolves to default in migration mode', () => { + const keys = buildSessionMessageCacheCandidateKeys('proj-a', 'sess-1', 'claude', { + allowLegacyFallback: true, + }); + + 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..943a5512 --- /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; + shouldRebindTemporarySession?: 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..99b61001 --- /dev/null +++ b/src/components/chat/utils/sessionMessageCache.ts @@ -0,0 +1,34 @@ +import type { Provider } from '../types/types'; +import { buildChatMessagesStorageKey } from './chatStorage'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; + +const LEGACY_CHAT_MESSAGES_PREFIX = 'chat_messages_'; + +type SessionMessageCacheLookupOptions = { + allowLegacyFallback?: boolean; +}; + +export function buildSessionMessageCacheCandidateKeys( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, + options: SessionMessageCacheLookupOptions = {}, +): string[] { + if (!projectName || !sessionId) { + return []; + } + + const normalizedProvider = normalizeProvider((provider || DEFAULT_PROVIDER) as Provider); + const providerScopedKey = buildChatMessagesStorageKey(projectName, sessionId, normalizedProvider); + if (!options.allowLegacyFallback) { + return providerScopedKey ? [providerScopedKey] : []; + } + + return Array.from( + new Set([ + providerScopedKey, + 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 7e9890f7..277e7361 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -230,6 +230,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(() => { @@ -1027,7 +1029,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({