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/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 7f3c14ca..ef969602 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, ClipboardEvent, @@ -8,24 +8,42 @@ import type { MouseEvent, SetStateAction, TouchEvent, -} from 'react'; -import { useDropzone } from 'react-dropzone'; -import type { FileRejection } from 'react-dropzone'; -import { useTranslation } from 'react-i18next'; -import { authenticatedFetch } from '../../../utils/api'; -import { isTelemetryEnabled } from '../../../utils/telemetry'; - -import { thinkingModes } from '../constants/thinkingModes'; -import type { CodexReasoningEffortId } from '../constants/codexReasoningEfforts'; -import { getSupportedCodexReasoningEfforts } from '../constants/codexReasoningSupport'; -import type { GeminiThinkingModeId } from '../../../../shared/geminiThinkingSupport'; -import { getSupportedGeminiThinkingModes } from '../../../../shared/geminiThinkingSupport'; - -import { grantToolPermission } from '../utils/chatPermissions'; -import { clearSessionTimerStart, getProviderSettingsKey, persistSessionTimerStart, safeLocalStorage } from '../utils/chatStorage'; -import { consumeWorkspaceQaDraft, WORKSPACE_QA_DRAFT_EVENT } from '../../../utils/workspaceQa'; -import { consumeReferenceChatDraft, REFERENCE_CHAT_DRAFT_EVENT } from '../../../utils/referenceChatDraft'; -import { consumeSkillCommandDraft, SKILL_COMMAND_DRAFT_EVENT } from '../../../utils/skillCommandDraft'; +} from "react"; +import { useDropzone } from "react-dropzone"; +import type { FileRejection } from "react-dropzone"; +import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { authenticatedFetch } from "../../../utils/api"; +import { isTelemetryEnabled } from "../../../utils/telemetry"; + +import { thinkingModes } from "../constants/thinkingModes"; +import type { CodexReasoningEffortId } from "../constants/codexReasoningEfforts"; +import { getSupportedCodexReasoningEfforts } from "../constants/codexReasoningSupport"; +import type { GeminiThinkingModeId } from "../../../../shared/geminiThinkingSupport"; +import { getSupportedGeminiThinkingModes } from "../../../../shared/geminiThinkingSupport"; + +import { grantToolPermission } from "../utils/chatPermissions"; +import { + buildDraftInputStorageKey, + clearScopedPendingSessionId, + clearScopedProviderSessionId, + clearSessionTimerStart, + getProviderSettingsKey, + persistScopedPendingSessionId, + persistScopedProviderSessionId, + persistSessionTimerStart, + readScopedPendingSessionId, + readScopedProviderSessionId, + safeLocalStorage, +} from "../utils/chatStorage"; +import { + consumeWorkspaceQaDraft, + WORKSPACE_QA_DRAFT_EVENT, +} from "../../../utils/workspaceQa"; +import { + consumeReferenceChatDraft, + REFERENCE_CHAT_DRAFT_EVENT, +} from "../../../utils/referenceChatDraft"; import type { AttachedPrompt, ChatAttachment, @@ -33,29 +51,54 @@ import type { ChatMessage, PendingPermissionRequest, PermissionMode, + QueuedTurn, + QueuedTurnKind, + QueuedTurnStatus, TokenBudget, -} from '../types/types'; -import { useFileMentions } from './useFileMentions'; -import { type SlashCommand, useSlashCommands } from './useSlashCommands'; -import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; -import { escapeRegExp } from '../utils/chatFormatting'; -import { isAutoResearchScenario } from '../utils/autoResearch'; -import type { SessionMode } from '../../../types/app'; -import type { BtwOverlayState } from '../view/subcomponents/BtwOverlay'; - -const CLOSED_BTW_OVERLAY: BtwOverlayState = { - open: false, - question: '', - answer: '', - loading: false, - error: null, -}; +} from "../types/types"; +import { useFileMentions } from "./useFileMentions"; +import { type SlashCommand, useSlashCommands } from "./useSlashCommands"; +import type { + Project, + ProjectSession, + SessionProvider, +} from "../../../types/app"; +import { escapeRegExp } from "../utils/chatFormatting"; +import type { SessionMode } from "../../../types/app"; +import { normalizeProvider } from "../../../utils/providerPolicy"; +import { + buildSessionScopeKey, + parseSessionScopeKey, + scopeKeyMatchesSessionId, +} from "../../../utils/sessionScope"; +import { + buildQueuedTurn, + enqueueSessionTurn, + getNextDispatchableTurn, + getSessionQueue, + promoteQueuedTurnToSteer, + reconcileSessionQueueId, + reconcileSettledSessionQueue, + removeQueuedTurn, + setSessionQueueStatus, + type SessionQueueMap, +} from "../utils/codexQueue"; +import { + OPTIMISTIC_SESSION_CREATED_EVENT, + type OptimisticSessionCreatedDetail, +} from "../../../constants/sessionEvents"; +import type { BtwOverlayState } from "../view/subcomponents/BtwOverlay"; type PendingViewSession = { sessionId: string | null; startedAt: number; }; +type PendingCodexQueueDispatch = { + turn: QueuedTurn; + shouldUpdateForegroundState: boolean; +}; + interface UseChatComposerStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; @@ -75,19 +118,38 @@ interface UseChatComposerStateArgs { tokenBudget: TokenBudget | null; sendMessage: (message: unknown) => void; sendByCtrlEnter?: boolean; - onSessionActive?: (sessionId?: string | null) => void; + onSessionActive?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionProcessing?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; onInputFocusChange?: (focused: boolean) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; + processingSessions?: Set; pendingViewSessionRef: { current: PendingViewSession | null }; scrollToBottom: () => void; setChatMessages: Dispatch>; setSessionMessages?: Dispatch>; setIsLoading: (loading: boolean) => void; setCanAbortSession: (canAbort: boolean) => void; - setClaudeStatus: Dispatch>; + setClaudeStatus: Dispatch< + SetStateAction<{ + text: string; + tokens: number; + can_interrupt: boolean; + startTime?: number; + } | null> + >; setIsUserScrolledUp: (isScrolledUp: boolean) => void; - setPendingPermissionRequests: Dispatch>; + setPendingPermissionRequests: Dispatch< + SetStateAction + >; newSessionMode?: SessionMode; /** Current chat messages for /btw context. */ getChatMessagesForBtw?: () => ChatMessage[]; @@ -99,7 +161,7 @@ interface MentionableFile { } interface CommandExecutionResult { - type: 'builtin' | 'custom'; + type: "builtin" | "custom"; action?: string; data?: any; content?: string; @@ -114,28 +176,39 @@ interface UploadedProjectFile { } const createFakeSubmitEvent = () => { - return { preventDefault: () => undefined } as unknown as FormEvent; + return { + preventDefault: () => undefined, + } as unknown as FormEvent; }; const PROGRAMMATIC_SUBMIT_MAX_RETRIES = 12; const PROGRAMMATIC_SUBMIT_RETRY_DELAY_MS = 50; +const CODEX_QUEUE_DISPATCH_AFTER_SETTLE_MS = 800; +const CODEX_QUEUE_DISPATCH_ACK_TIMEOUT_MS = 6000; const MAX_ATTACHMENTS = 5; const MAX_ATTACHMENT_SIZE_BYTES = 50 * 1024 * 1024; -const CODEX_ATTACHMENT_DIR = '.dr-claw/chat-attachments'; +const CODEX_ATTACHMENT_DIR = ".dr-claw/chat-attachments"; +const CLOSED_BTW_OVERLAY: BtwOverlayState = { + open: false, + question: "", + answer: "", + loading: false, + error: null, +}; const IMAGE_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.bmp', - '.svg', - '.heic', - '.heif', + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".svg", + ".heic", + ".heif", ]); -const PDF_EXTENSION = '.pdf'; +const PDF_EXTENSION = ".pdf"; function getAttachmentKey(file: File) { return `${file.name}:${file.size}:${file.lastModified}`; @@ -143,49 +216,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; @@ -216,7 +300,7 @@ function buildBtwTranscript(messages: ChatMessage[]): string { } const getRouteSessionId = () => { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return null; } @@ -232,6 +316,100 @@ const getRouteSessionId = () => { } }; +const getOptimisticSessionDisplayName = (input: string) => { + const firstLine = input + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + + if (!firstLine) { + return "New Session"; + } + + return firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine; +}; + +const getCodexQueueStorageKey = (projectName: string) => + `codex_queue_${projectName}`; + +const readPersistedCodexQueue = ( + projectName?: string | null, +): SessionQueueMap => { + if (!projectName) { + return {}; + } + + try { + const raw = safeLocalStorage.getItem(getCodexQueueStorageKey(projectName)); + if (!raw) { + return {}; + } + + const parsed = JSON.parse(raw) as Record; + if (!parsed || typeof parsed !== "object") { + return {}; + } + + const normalized: SessionQueueMap = {}; + for (const [sessionId, queue] of Object.entries(parsed)) { + if (!Array.isArray(queue)) { + continue; + } + + const turns: QueuedTurn[] = []; + for (const candidate of queue) { + if (!candidate || typeof candidate !== "object") { + continue; + } + + const turn = { + id: + typeof (candidate as any).id === "string" + ? (candidate as any).id + : `queued-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + sessionId, + text: + typeof (candidate as any).text === "string" + ? (candidate as any).text + : "", + kind: (candidate as any).kind === "steer" ? "steer" : "normal", + status: (candidate as any).status === "paused" ? "paused" : "queued", + createdAt: + typeof (candidate as any).createdAt === "number" + ? (candidate as any).createdAt + : Date.now(), + projectName: + typeof (candidate as any).projectName === "string" + ? (candidate as any).projectName + : undefined, + projectPath: + typeof (candidate as any).projectPath === "string" + ? (candidate as any).projectPath + : undefined, + sessionMode: + (candidate as any).sessionMode === "workspace_qa" + ? "workspace_qa" + : "research", + } satisfies QueuedTurn; + + if (!turn.text.trim()) { + continue; + } + + turns.push(turn); + } + + if (turns.length > 0) { + normalized[sessionId] = turns; + } + } + + return normalized; + } catch { + return {}; + } +}; + export function useChatComposerState({ selectedProject, selectedSession, @@ -252,9 +430,11 @@ export function useChatComposerState({ sendMessage, sendByCtrlEnter, onSessionActive, + onSessionProcessing, onInputFocusChange, onFileOpen, onShowSettings, + processingSessions, pendingViewSessionRef, scrollToBottom, setChatMessages, @@ -264,54 +444,66 @@ export function useChatComposerState({ setClaudeStatus, setIsUserScrolledUp, setPendingPermissionRequests, - newSessionMode = 'research', + newSessionMode = "research", getChatMessagesForBtw, }: UseChatComposerStateArgs) { - const { t } = useTranslation('chat'); + const { t } = useTranslation("chat"); + const { pathname } = useLocation(); + const initialDraftBucket = + selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || "new"; + const initialDraftStorageKey = buildDraftInputStorageKey( + selectedProject?.name || null, + normalizeProvider(provider), + initialDraftBucket, + ); const [input, setInput] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; + if (typeof window !== "undefined" && initialDraftStorageKey) { + return safeLocalStorage.getItem(initialDraftStorageKey) || ""; } - return ''; + return ""; }); const [attachedFiles, setAttachedFiles] = useState([]); - const [uploadingFiles, setUploadingFiles] = useState>(new Map()); + const [uploadingFiles, setUploadingFiles] = useState>( + new Map(), + ); const [fileErrors, setFileErrors] = useState>(new Map()); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); - const [thinkingMode, setThinkingMode] = useState('none'); - const [codexReasoningEffort, setCodexReasoningEffort] = useState(() => { - const savedValue = safeLocalStorage.getItem('codex-reasoning-effort'); - switch (savedValue) { - case 'minimal': - case 'low': - case 'medium': - case 'high': - case 'xhigh': - case 'default': - return savedValue; - default: - return 'default'; + const [thinkingMode, setThinkingMode] = useState("none"); + const [codexReasoningEffort, setCodexReasoningEffort] = + useState(() => { + const savedValue = safeLocalStorage.getItem("codex-reasoning-effort"); + switch (savedValue) { + case "minimal": + case "low": + case "medium": + case "high": + case "xhigh": + case "default": + return savedValue; + default: + return "default"; } - }); - const [geminiThinkingMode, setGeminiThinkingMode] = useState(() => { - const savedValue = safeLocalStorage.getItem('gemini-thinking-mode'); - switch (savedValue) { - case 'default': - case 'minimal': - case 'low': - case 'medium': - case 'high': - case 'dynamic': - case 'off': - case 'light': - case 'balanced': - case 'deep': - case 'max': - return savedValue; - default: - return 'default'; - } - }); + }); + const [geminiThinkingMode, setGeminiThinkingMode] = + useState(() => { + const savedValue = safeLocalStorage.getItem("gemini-thinking-mode"); + switch (savedValue) { + case "default": + case "minimal": + case "low": + case "medium": + case "high": + case "dynamic": + case "off": + case "light": + case "balanced": + case "deep": + case "max": + return savedValue; + default: + return "default"; + } + }); const [intakeGreeting, setIntakeGreeting] = useState(null); const [btwOverlay, setBtwOverlay] = useState(CLOSED_BTW_OVERLAY); const btwAbortRef = useRef(null); @@ -321,15 +513,112 @@ export function useChatComposerState({ setBtwOverlay(CLOSED_BTW_OVERLAY); }, []); const [pendingStageTagKeys, setPendingStageTagKeys] = useState([]); - const [attachedPrompt, setAttachedPrompt] = useState(null); + const [attachedPrompt, setAttachedPrompt] = useState( + null, + ); + const [steerMode, setSteerMode] = useState(false); + const [isQueueBootstrapReady, setIsQueueBootstrapReady] = useState(false); + const [queuedTurnsBySession, setQueuedTurnsBySession] = + useState(() => + readPersistedCodexQueue(selectedProject?.name), + ); const textareaRef = useRef(null); const inputHighlightRef = useRef(null); const handleSubmitRef = useRef< - ((event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent) => Promise) | null + | (( + event: + | FormEvent + | MouseEvent + | TouchEvent + | KeyboardEvent, + ) => Promise) + | null >(null); const inputValueRef = useRef(input); const abortTimeoutRef = useRef | null>(null); + const queuedTurnsBySessionRef = useRef(queuedTurnsBySession); + const queueDispatchLocksRef = useRef>(new Set()); + const pendingQueueDispatchesRef = useRef< + Map + >(new Map()); + const queueDispatchAckTimersRef = useRef< + Map> + >(new Map()); + const codexBusyRejectedDispatchesRef = useRef>(new Set()); + const queueBootstrapTimerRef = useRef | null>( + null, + ); + const lastSubmittedCodexSessionRef = useRef(null); + const forceSteerForSubmitRef = useRef(false); + const normalizedProvider = normalizeProvider(provider); + const currentProjectName = selectedProject?.name || null; + const activeDraftBucket = + selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || "new"; + const draftStorageKey = useMemo( + () => + buildDraftInputStorageKey( + currentProjectName, + normalizedProvider, + activeDraftBucket, + ), + [activeDraftBucket, currentProjectName, normalizedProvider], + ); + + const hasProcessingSession = useCallback( + ( + sessionId: string | null | undefined, + providerOverride: SessionProvider | string | null | undefined = normalizedProvider, + projectNameOverride: string | null | undefined = currentProjectName, + ) => { + if (!sessionId || !processingSessions || !projectNameOverride) { + return false; + } + + const scopeKey = buildSessionScopeKey( + projectNameOverride, + providerOverride || normalizedProvider, + sessionId, + ); + + if (scopeKey && processingSessions.has(scopeKey)) { + return true; + } + + if (processingSessions.has(sessionId)) { + return true; + } + + const normalizedProviderOverride = normalizeProvider( + (providerOverride || normalizedProvider) as SessionProvider, + ); + + return Array.from(processingSessions).some((trackingKey) => { + if (!scopeKeyMatchesSessionId(trackingKey, sessionId)) { + return false; + } + + const parsedScope = parseSessionScopeKey(trackingKey); + if (!parsedScope) { + return trackingKey === sessionId; + } + + return ( + parsedScope.projectName === projectNameOverride && + parsedScope.provider === normalizedProviderOverride + ); + }); + }, + [currentProjectName, normalizedProvider, processingSessions], + ); + + useEffect(() => { + queuedTurnsBySessionRef.current = queuedTurnsBySession; + }, [queuedTurnsBySession]); + + useEffect(() => { + setQueuedTurnsBySession(readPersistedCodexQueue(selectedProject?.name)); + }, [selectedProject?.name]); useEffect(() => { return () => { @@ -337,32 +626,92 @@ export function useChatComposerState({ clearTimeout(abortTimeoutRef.current); abortTimeoutRef.current = null; } + + if (queueBootstrapTimerRef.current) { + clearTimeout(queueBootstrapTimerRef.current); + queueBootstrapTimerRef.current = null; + } + + for (const timerId of queueDispatchAckTimersRef.current.values()) { + clearTimeout(timerId); + } + queueDispatchAckTimersRef.current.clear(); + queueDispatchLocksRef.current.clear(); + pendingQueueDispatchesRef.current.clear(); + codexBusyRejectedDispatchesRef.current.clear(); }; }, []); + useEffect(() => { + setIsQueueBootstrapReady(false); + if (queueBootstrapTimerRef.current) { + clearTimeout(queueBootstrapTimerRef.current); + queueBootstrapTimerRef.current = null; + } + }, [selectedProject?.name]); + + useEffect(() => { + if (!selectedProject?.name) { + return; + } + + safeLocalStorage.setItem( + getCodexQueueStorageKey(selectedProject.name), + JSON.stringify(queuedTurnsBySession), + ); + }, [queuedTurnsBySession, selectedProject?.name]); + + useEffect(() => { + if (isQueueBootstrapReady || queueBootstrapTimerRef.current) { + return; + } + + const queuedSessionIds = Object.entries(queuedTurnsBySession) + .filter(([, queue]) => queue.some((turn) => turn.status === "queued")) + .map(([sessionId]) => sessionId); + + if (queuedSessionIds.length === 0) { + setIsQueueBootstrapReady(true); + return; + } + + queuedSessionIds.forEach((sessionId) => { + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + }); + + queueBootstrapTimerRef.current = setTimeout(() => { + setIsQueueBootstrapReady(true); + queueBootstrapTimerRef.current = null; + }, 1200); + }, [isQueueBootstrapReady, queuedTurnsBySession, sendMessage]); + useEffect(() => { setPendingStageTagKeys([]); }, [selectedProject?.name, selectedSession?.id]); useEffect(() => { - safeLocalStorage.setItem('codex-reasoning-effort', codexReasoningEffort); + safeLocalStorage.setItem("codex-reasoning-effort", codexReasoningEffort); }, [codexReasoningEffort]); useEffect(() => { - safeLocalStorage.setItem('gemini-thinking-mode', geminiThinkingMode); + safeLocalStorage.setItem("gemini-thinking-mode", geminiThinkingMode); }, [geminiThinkingMode]); useEffect(() => { const supportedEfforts = getSupportedCodexReasoningEfforts(codexModel); if (!supportedEfforts.includes(codexReasoningEffort)) { - setCodexReasoningEffort('default'); + setCodexReasoningEffort("default"); } }, [codexModel, codexReasoningEffort]); useEffect(() => { const supportedModes = getSupportedGeminiThinkingModes(geminiModel); if (!supportedModes.includes(geminiThinkingMode)) { - setGeminiThinkingMode('default'); + setGeminiThinkingMode("default"); } }, [geminiModel, geminiThinkingMode]); @@ -370,58 +719,62 @@ export function useChatComposerState({ (result: CommandExecutionResult) => { const { action, data } = result; switch (action) { - case 'clear': + case "clear": setChatMessages([]); setSessionMessages?.([]); break; - case 'help': + case "help": setChatMessages((previous) => [ ...previous, { - type: 'assistant', + type: "assistant", content: data.content, timestamp: Date.now(), }, ]); break; - case 'model': + case "model": setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`, + type: "assistant", + content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(", ")}\n\nCursor: ${data.available.cursor.join(", ")}`, timestamp: Date.now(), }, ]); break; - case 'cost': { + case "cost": { const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`; setChatMessages((previous) => [ ...previous, - { type: 'assistant', content: costMessage, timestamp: Date.now() }, + { type: "assistant", content: costMessage, timestamp: Date.now() }, ]); break; } - case 'status': { + case "status": { const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; setChatMessages((previous) => [ ...previous, - { type: 'assistant', content: statusMessage, timestamp: Date.now() }, + { + type: "assistant", + content: statusMessage, + timestamp: Date.now(), + }, ]); break; } - case 'memory': + case "memory": if (data.error) { setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `⚠️ ${data.message}`, + type: "assistant", + content: `閳跨媴绗?${data.message}`, timestamp: Date.now(), }, ]); @@ -429,8 +782,8 @@ export function useChatComposerState({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `📝 ${data.message}\n\nPath: \`${data.path}\``, + type: "assistant", + content: `棣冩憫 ${data.message}\n\nPath: \`${data.path}\``, timestamp: Date.now(), }, ]); @@ -440,17 +793,17 @@ export function useChatComposerState({ } break; - case 'config': + case "config": onShowSettings?.(); break; - case 'rewind': + case "rewind": if (data.error) { setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `⚠️ ${data.message}`, + type: "assistant", + content: `閳跨媴绗?${data.message}`, timestamp: Date.now(), }, ]); @@ -459,8 +812,8 @@ export function useChatComposerState({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `⏪ ${data.message}`, + type: "assistant", + content: `閳?${data.message}`, timestamp: Date.now(), }, ]); @@ -468,46 +821,49 @@ export function useChatComposerState({ break; default: - console.warn('Unknown built-in command action:', action); + console.warn("Unknown built-in command action:", action); } }, [onFileOpen, onShowSettings, setChatMessages, setSessionMessages], ); - const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => { - const { content, hasBashCommands } = result; + const handleCustomCommand = useCallback( + async (result: CommandExecutionResult) => { + const { content, hasBashCommands } = result; - if (hasBashCommands) { - const confirmed = window.confirm( - 'This command contains bash commands that will be executed. Do you want to proceed?', - ); - if (!confirmed) { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: '❌ Command execution cancelled', - timestamp: Date.now(), - }, - ]); - return; + if (hasBashCommands) { + const confirmed = window.confirm( + "This command contains bash commands that will be executed. Do you want to proceed?", + ); + if (!confirmed) { + setChatMessages((previous) => [ + ...previous, + { + type: "assistant", + content: "閴?Command execution cancelled", + timestamp: Date.now(), + }, + ]); + return; + } } - } - const commandContent = content || ''; - setInput(commandContent); - inputValueRef.current = commandContent; + const commandContent = content || ""; + setInput(commandContent); + inputValueRef.current = commandContent; - // Defer submit to next tick so the command text is reflected in UI before dispatching. - setTimeout(() => { - if (handleSubmitRef.current) { - handleSubmitRef.current(createFakeSubmitEvent()); - } - }, 0); - }, [setChatMessages]); + // Defer submit to next tick so the command text is reflected in UI before dispatching. + setTimeout(() => { + if (handleSubmitRef.current) { + handleSubmitRef.current(createFakeSubmitEvent()); + } + }, 0); + }, + [setChatMessages], + ); const submitProgrammaticInput = useCallback((content: string) => { - const nextContent = content || ''; + const nextContent = content || ""; setInput(nextContent); inputValueRef.current = nextContent; @@ -518,7 +874,9 @@ export function useChatComposerState({ } if (attempt >= PROGRAMMATIC_SUBMIT_MAX_RETRIES) { - console.warn('[Chat] Programmatic submit skipped because handleSubmit was not ready'); + console.warn( + "[Chat] Programmatic submit skipped because handleSubmit was not ready", + ); return; } @@ -540,9 +898,13 @@ export function useChatComposerState({ try { const effectiveInput = rawInput ?? input; - const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`)); + const commandMatch = effectiveInput.match( + new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`), + ); const args = - commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : []; + commandMatch && commandMatch[1] + ? commandMatch[1].trim().split(/\s+/) + : []; const context = { projectPath: selectedProject.fullPath || selectedProject.path, @@ -550,26 +912,18 @@ export function useChatComposerState({ sessionId: currentSessionId, provider, model: - provider === 'cursor' + provider === "cursor" ? cursorModel - : provider === 'codex' + : provider === "codex" ? codexModel - : provider === 'gemini' - ? geminiModel - : provider === 'openrouter' - ? openrouterModel - : provider === 'local' - ? localModel - : provider === 'nano' - ? nanoModel - : claudeModel, + : claudeModel, tokenUsage: tokenBudget, }; - const response = await authenticatedFetch('/api/commands/execute', { - method: 'POST', + const response = await authenticatedFetch("/api/commands/execute", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ commandName: command.name, @@ -583,7 +937,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. } @@ -683,20 +1038,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(), }, @@ -772,8 +1128,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; } @@ -782,7 +1138,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; @@ -791,7 +1150,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; @@ -799,7 +1161,7 @@ export function useChatComposerState({ validFiles.push(file); } catch (error) { - console.error('Error validating file:', error, file); + console.error("Error validating file:", error, file); } }); @@ -816,7 +1178,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); } }); @@ -868,7 +1232,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(); @@ -904,17 +1268,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(); @@ -931,53 +1298,689 @@ export function useChatComposerState({ const formData = new FormData(); const targetDir = `${CODEX_ATTACHMENT_DIR}/${Date.now()}`; - formData.append('targetDir', targetDir); + formData.append("targetDir", targetDir); files.forEach((file) => { - formData.append('files', file); + formData.append("files", file); }); - const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(selectedProject.name)}/upload-files`, { - method: 'POST', - headers: {}, - body: formData, - }); + const response = await authenticatedFetch( + `/api/projects/${encodeURIComponent(selectedProject.name)}/upload-files`, + { + method: "POST", + headers: {}, + body: formData, + }, + ); if (!response.ok) { - throw new Error('Failed to upload files'); + throw new Error("Failed to upload files"); } const result = await response.json(); - return Array.isArray(result.files) ? (result.files as UploadedProjectFile[]) : []; + return Array.isArray(result.files) + ? (result.files as UploadedProjectFile[]) + : []; }, [selectedProject], ); + const resolveSessionContext = useCallback(() => { + const routedSessionId = getRouteSessionId(); + const projectName = selectedProject?.name || null; + const resolvedProvider = normalizeProvider(provider); + + // If we're on the root path with no routed session and no selected session, + // treat this as an explicit new-session start and clear stale IDs. + const isExplicitNewSessionStart = + pathname === "/" && + !routedSessionId && + !selectedSession?.id; + if (isExplicitNewSessionStart) { + clearScopedProviderSessionId(projectName, "gemini"); + clearScopedProviderSessionId(projectName, "cursor"); + clearScopedPendingSessionId(projectName, "claude"); + clearScopedPendingSessionId(projectName, "cursor"); + clearScopedPendingSessionId(projectName, "codex"); + clearScopedPendingSessionId(projectName, "gemini"); + clearScopedPendingSessionId(projectName, "openrouter"); + clearScopedPendingSessionId(projectName, "local"); + lastSubmittedCodexSessionRef.current = null; + } + + const providerSessionId = + resolvedProvider === "gemini" || resolvedProvider === "cursor" + ? readScopedProviderSessionId(projectName, resolvedProvider) + : null; + const pendingSessionId = readScopedPendingSessionId(projectName, resolvedProvider); + const pendingViewSessionId = + pendingViewSessionRef.current?.sessionId || null; + const lastSubmittedSessionId = + provider === "codex" ? lastSubmittedCodexSessionRef.current : null; + const effectiveSessionId = + currentSessionId || + selectedSession?.id || + routedSessionId || + pendingViewSessionId || + pendingSessionId || + providerSessionId || + lastSubmittedSessionId; + const isNewSession = !effectiveSessionId; + const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; + const resolvedProjectPath = + selectedProject?.fullPath || selectedProject?.path || ""; + + return { + routedSessionId, + effectiveSessionId, + isNewSession, + sessionToActivate, + resolvedProvider, + resolvedProjectPath, + }; + }, [ + currentSessionId, + pathname, + pendingViewSessionRef, + provider, + selectedProject?.fullPath, + selectedProject?.path, + selectedSession?.id, + ]); + + const getToolsSettings = useCallback((resolvedProvider: SessionProvider) => { + try { + const settingsKey = getProviderSettingsKey(resolvedProvider); + const savedSettings = safeLocalStorage.getItem(settingsKey); + if (savedSettings) { + return JSON.parse(savedSettings); + } + } catch (error) { + console.error("Error loading tools settings:", error); + } + + return { + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + }; + }, []); + + const sendCodexTurn = useCallback( + ({ + text, + sessionId, + projectName, + projectPath, + sessionMode, + updateForegroundState = true, + appendLocalUserMessage = true, + }: { + text: string; + sessionId: string; + projectName?: string | null; + projectPath: string; + sessionMode: SessionMode; + updateForegroundState?: boolean; + appendLocalUserMessage?: boolean; + }) => { + if (!text.trim() || !sessionId || !projectPath) { + return; + } + + const turnStartTime = Date.now(); + + if (appendLocalUserMessage) { + setChatMessages((previous) => [ + ...previous, + { + type: "user", + content: text, + timestamp: new Date(), + }, + ]); + } + + if (updateForegroundState) { + setIsLoading(true); + setCanAbortSession(true); + setClaudeStatus({ + text: "Processing", + tokens: 0, + can_interrupt: true, + startTime: turnStartTime, + }); + setIsUserScrolledUp(false); + setTimeout(() => scrollToBottom(), 100); + } + + persistSessionTimerStart(sessionId, turnStartTime); + onSessionActive?.(sessionId, "codex", projectName || selectedProject?.name || null); + onSessionProcessing?.( + sessionId, + "codex", + projectName || selectedProject?.name || null, + ); + lastSubmittedCodexSessionRef.current = sessionId; + + sendMessage({ + type: "codex-command", + command: text, + sessionId, + options: { + cwd: projectPath, + projectPath, + sessionId, + resume: true, + model: codexModel, + permissionMode: + permissionMode === "plan" ? "default" : permissionMode, + modelReasoningEffort: + codexReasoningEffort === "default" + ? undefined + : codexReasoningEffort, + telemetryEnabled: isTelemetryEnabled(), + sessionMode, + }, + }); + }, + [ + codexModel, + codexReasoningEffort, + onSessionActive, + onSessionProcessing, + permissionMode, + scrollToBottom, + sendMessage, + setCanAbortSession, + setChatMessages, + setClaudeStatus, + setIsLoading, + setIsUserScrolledUp, + selectedProject?.name, + ], + ); + + const clearQueueDispatchAckTimer = useCallback((sessionId: string) => { + const timerId = queueDispatchAckTimersRef.current.get(sessionId); + if (timerId) { + clearTimeout(timerId); + queueDispatchAckTimersRef.current.delete(sessionId); + } + }, []); + + const releasePendingQueueDispatch = useCallback( + (sessionId: string) => { + clearQueueDispatchAckTimer(sessionId); + queueDispatchLocksRef.current.delete(sessionId); + pendingQueueDispatchesRef.current.delete(sessionId); + codexBusyRejectedDispatchesRef.current.delete(sessionId); + }, + [clearQueueDispatchAckTimer], + ); + + const dispatchNextQueuedCodexTurn = useCallback( + (sessionId: string, options?: { ignoreProcessingCheck?: boolean }) => { + if (!sessionId) { + return; + } + + if ( + queueDispatchLocksRef.current.has(sessionId) || + pendingQueueDispatchesRef.current.has(sessionId) + ) { + return; + } + + const queue = getSessionQueue(queuedTurnsBySessionRef.current, sessionId); + const nextTurn = getNextDispatchableTurn(queue); + if (!nextTurn) { + return; + } + + if ( + !options?.ignoreProcessingCheck && + hasProcessingSession( + sessionId, + "codex", + nextTurn.projectName || selectedProject?.name || currentProjectName, + ) + ) { + return; + } + + const queuedProjectPath = + nextTurn.projectPath || + selectedProject?.fullPath || + selectedProject?.path || + ""; + if (!queuedProjectPath) { + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: + "Unable to execute queued message: project path is unavailable.", + timestamp: new Date(), + }, + ]); + return; + } + + const routedSessionId = getRouteSessionId(); + const currentViewSessionId = + selectedSession?.id || + routedSessionId || + currentSessionId || + pendingViewSessionRef.current?.sessionId || + null; + const shouldUpdateForegroundState = + currentViewSessionId === sessionId || + (!currentViewSessionId && + lastSubmittedCodexSessionRef.current === sessionId); + + queueDispatchLocksRef.current.add(sessionId); + pendingQueueDispatchesRef.current.set(sessionId, { + turn: nextTurn, + shouldUpdateForegroundState, + }); + + clearQueueDispatchAckTimer(sessionId); + const ackTimer = setTimeout(() => { + const pendingDispatch = + pendingQueueDispatchesRef.current.get(sessionId); + if (!pendingDispatch || pendingDispatch.turn.id !== nextTurn.id) { + return; + } + + queueDispatchAckTimersRef.current.delete(sessionId); + queueDispatchLocksRef.current.delete(sessionId); + pendingQueueDispatchesRef.current.delete(sessionId); + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + }, CODEX_QUEUE_DISPATCH_ACK_TIMEOUT_MS); + queueDispatchAckTimersRef.current.set(sessionId, ackTimer); + + sendCodexTurn({ + text: nextTurn.text, + sessionId, + projectName: nextTurn.projectName || selectedProject?.name || currentProjectName, + projectPath: queuedProjectPath, + sessionMode: + nextTurn.sessionMode || selectedSession?.mode || newSessionMode, + updateForegroundState: shouldUpdateForegroundState, + appendLocalUserMessage: false, + }); + }, + [ + clearQueueDispatchAckTimer, + currentSessionId, + newSessionMode, + pendingViewSessionRef, + hasProcessingSession, + selectedProject?.fullPath, + selectedProject?.name, + selectedProject?.path, + selectedSession?.id, + selectedSession?.mode, + currentProjectName, + sendCodexTurn, + sendMessage, + setChatMessages, + ], + ); + + const handleCodexTurnStarted = useCallback( + (sessionId?: string | null) => { + if (!sessionId) { + return; + } + + codexBusyRejectedDispatchesRef.current.delete(sessionId); + const pendingDispatch = pendingQueueDispatchesRef.current.get(sessionId); + if (!pendingDispatch) { + return; + } + + releasePendingQueueDispatch(sessionId); + setQueuedTurnsBySession((previous) => + removeQueuedTurn(previous, sessionId, pendingDispatch.turn.id), + ); + + if (pendingDispatch.shouldUpdateForegroundState) { + setChatMessages((previous) => [ + ...previous, + { + type: "user", + content: pendingDispatch.turn.text, + timestamp: new Date(), + }, + ]); + } + }, + [releasePendingQueueDispatch, setChatMessages], + ); + + const handleCodexTurnSettled = useCallback( + ( + sessionId?: string | null, + outcome: "complete" | "error" | "aborted" = "complete", + ) => { + if (!sessionId) { + return; + } + + codexBusyRejectedDispatchesRef.current.delete(sessionId); + releasePendingQueueDispatch(sessionId); + + if (outcome === "aborted") { + setQueuedTurnsBySession((previous) => + setSessionQueueStatus(previous, sessionId, "paused"), + ); + return; + } + + const fallbackTemporarySessionId = lastSubmittedCodexSessionRef.current; + if (fallbackTemporarySessionId) { + const reconciled = reconcileSettledSessionQueue( + queuedTurnsBySessionRef.current, + sessionId, + fallbackTemporarySessionId, + ); + if (reconciled !== queuedTurnsBySessionRef.current) { + queuedTurnsBySessionRef.current = reconciled; + setQueuedTurnsBySession(reconciled); + } + } + + queueDispatchLocksRef.current.add(sessionId); + setTimeout(() => { + queueDispatchLocksRef.current.delete(sessionId); + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + dispatchNextQueuedCodexTurn(sessionId); + }, CODEX_QUEUE_DISPATCH_AFTER_SETTLE_MS); + }, + [dispatchNextQueuedCodexTurn, releasePendingQueueDispatch, sendMessage], + ); + + const handleCodexSessionIdResolved = useCallback( + (previousSessionId?: string | null, actualSessionId?: string | null) => { + if ( + !previousSessionId || + !actualSessionId || + previousSessionId === actualSessionId + ) { + return; + } + + if (lastSubmittedCodexSessionRef.current === previousSessionId) { + lastSubmittedCodexSessionRef.current = actualSessionId; + } + + if (codexBusyRejectedDispatchesRef.current.has(previousSessionId)) { + codexBusyRejectedDispatchesRef.current.delete(previousSessionId); + codexBusyRejectedDispatchesRef.current.add(actualSessionId); + } + + if (queueDispatchLocksRef.current.has(previousSessionId)) { + queueDispatchLocksRef.current.delete(previousSessionId); + queueDispatchLocksRef.current.add(actualSessionId); + } + + const pendingDispatch = + pendingQueueDispatchesRef.current.get(previousSessionId); + if ( + pendingDispatch && + !pendingQueueDispatchesRef.current.has(actualSessionId) + ) { + pendingQueueDispatchesRef.current.set(actualSessionId, { + ...pendingDispatch, + turn: { + ...pendingDispatch.turn, + sessionId: actualSessionId, + }, + }); + } + pendingQueueDispatchesRef.current.delete(previousSessionId); + + clearQueueDispatchAckTimer(previousSessionId); + if (pendingDispatch) { + const ackTimer = setTimeout(() => { + const currentPending = + pendingQueueDispatchesRef.current.get(actualSessionId); + if ( + !currentPending || + currentPending.turn.id !== pendingDispatch.turn.id + ) { + return; + } + + queueDispatchAckTimersRef.current.delete(actualSessionId); + queueDispatchLocksRef.current.delete(actualSessionId); + pendingQueueDispatchesRef.current.delete(actualSessionId); + sendMessage({ + type: "check-session-status", + sessionId: actualSessionId, + provider: "codex", + }); + }, CODEX_QUEUE_DISPATCH_ACK_TIMEOUT_MS); + queueDispatchAckTimersRef.current.set(actualSessionId, ackTimer); + } + + const reconciled = reconcileSessionQueueId( + queuedTurnsBySessionRef.current, + previousSessionId, + actualSessionId, + ); + if (reconciled === queuedTurnsBySessionRef.current) { + return; + } + + queuedTurnsBySessionRef.current = reconciled; + setQueuedTurnsBySession(reconciled); + }, + [clearQueueDispatchAckTimer, sendMessage], + ); + + const handleCodexSessionBusy = useCallback( + (sessionId?: string | null) => { + if (!sessionId) { + return; + } + + codexBusyRejectedDispatchesRef.current.add(sessionId); + releasePendingQueueDispatch(sessionId); + queueDispatchLocksRef.current.delete(sessionId); + + setTimeout(() => { + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + dispatchNextQueuedCodexTurn(sessionId); + }, CODEX_QUEUE_DISPATCH_AFTER_SETTLE_MS); + }, + [dispatchNextQueuedCodexTurn, releasePendingQueueDispatch, sendMessage], + ); + + const handleCodexSessionStatusUpdate = useCallback( + (sessionId?: string | null, isProcessing?: boolean) => { + if (!sessionId) { + return; + } + + if (isProcessing) { + if (codexBusyRejectedDispatchesRef.current.has(sessionId)) { + codexBusyRejectedDispatchesRef.current.delete(sessionId); + return; + } + if (pendingQueueDispatchesRef.current.has(sessionId)) { + handleCodexTurnStarted(sessionId); + } + return; + } + + releasePendingQueueDispatch(sessionId); + queueDispatchLocksRef.current.delete(sessionId); + dispatchNextQueuedCodexTurn(sessionId); + }, + [ + dispatchNextQueuedCodexTurn, + handleCodexTurnStarted, + releasePendingQueueDispatch, + ], + ); + + const removeQueuedCodexTurn = useCallback( + (sessionId: string, turnId: string) => { + if (!sessionId || !turnId) { + return; + } + + const pendingDispatch = pendingQueueDispatchesRef.current.get(sessionId); + if (pendingDispatch?.turn.id === turnId) { + releasePendingQueueDispatch(sessionId); + } + + setQueuedTurnsBySession((previous) => + removeQueuedTurn(previous, sessionId, turnId), + ); + }, + [releasePendingQueueDispatch], + ); + + const promoteQueuedCodexTurnToSteer = useCallback( + (sessionId: string, turnId: string) => { + if (!sessionId || !turnId) { + return; + } + + setQueuedTurnsBySession((previous) => + promoteQueuedTurnToSteer(previous, sessionId, turnId), + ); + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + dispatchNextQueuedCodexTurn(sessionId); + }, + [dispatchNextQueuedCodexTurn, sendMessage], + ); + + const resumeQueuedCodexTurns = useCallback( + (sessionId: string) => { + if (!sessionId) { + return; + } + + setQueuedTurnsBySession((previous) => + setSessionQueueStatus(previous, sessionId, "queued"), + ); + sendMessage({ + type: "check-session-status", + sessionId, + provider: "codex", + }); + dispatchNextQueuedCodexTurn(sessionId); + }, + [dispatchNextQueuedCodexTurn, sendMessage], + ); + + useEffect(() => { + if (!isQueueBootstrapReady) { + return; + } + + for (const [sessionId, queue] of Object.entries(queuedTurnsBySession)) { + if (!queue.some((turn) => turn.status === "queued")) { + continue; + } + + if ( + queueDispatchLocksRef.current.has(sessionId) || + pendingQueueDispatchesRef.current.has(sessionId) + ) { + continue; + } + + if (hasProcessingSession(sessionId, "codex", selectedProject?.name || currentProjectName)) { + continue; + } + + dispatchNextQueuedCodexTurn(sessionId); + } + }, [ + dispatchNextQueuedCodexTurn, + hasProcessingSession, + currentProjectName, + isQueueBootstrapReady, + queuedTurnsBySession, + selectedProject?.name, + ]); + + const activeQueueSessionId = + selectedSession?.id || + getRouteSessionId() || + currentSessionId || + pendingViewSessionRef.current?.sessionId || + lastSubmittedCodexSessionRef.current || + null; + const activeQueuedTurns = activeQueueSessionId + ? getSessionQueue(queuedTurnsBySession, activeQueueSessionId) + : []; + const isActiveQueuePaused = activeQueuedTurns.some( + (turn) => turn.status === "paused", + ); + const handleSubmit = useCallback( async ( - event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, + event: + | FormEvent + | MouseEvent + | TouchEvent + | KeyboardEvent, ) => { event.preventDefault(); + const forceSteerForSubmit = forceSteerForSubmitRef.current; + forceSteerForSubmitRef.current = false; const currentInput = inputValueRef.current; - if (!selectedProject) { + if ( + (!currentInput.trim() && + attachedFiles.length === 0 && + !attachedPrompt) || + !selectedProject + ) { return; } - if (!currentInput.trim() && attachedFiles.length === 0 && !attachedPrompt) { + + if (isLoading && provider !== "codex") { return; } const trimmedInput = currentInput.trim(); - if (trimmedInput.startsWith('/')) { - const firstSpace = trimmedInput.indexOf(' '); - const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput; - const matchedCommand = slashCommands.find((command: SlashCommand) => command.name === commandName); + if (trimmedInput.startsWith("/")) { + const firstSpace = trimmedInput.indexOf(" "); + const commandName = + firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput; + const matchedCommand = slashCommands.find( + (command: SlashCommand) => command.name === commandName, + ); if (matchedCommand) { if (isLoading && commandName !== '/btw') { return; } await executeCommand(matchedCommand, trimmedInput); - setInput(''); - inputValueRef.current = ''; + setInput(""); + inputValueRef.current = ""; setAttachedPrompt(null); setAttachedFiles([]); setUploadingFiles(new Map()); @@ -985,7 +1988,7 @@ export function useChatComposerState({ resetCommandMenuState(); setIsTextareaExpanded(false); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; } return; } @@ -997,12 +2000,12 @@ export function useChatComposerState({ const normalizedInput = currentInput.trim() || - t('input.attachmentOnlyFallback', { - defaultValue: 'Please inspect the attached files and help me with them.', + t("input.attachmentOnlyFallback", { + defaultValue: + "Please inspect the attached files and help me with them.", }); let messageContent = normalizedInput; - // Prepend attached prompt text if present if (attachedPrompt) { if (currentInput.trim()) { messageContent = `${attachedPrompt.promptText}\n\n${normalizedInput}`; @@ -1011,22 +2014,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(), }, @@ -1035,6 +2034,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: | { @@ -1050,12 +2128,13 @@ export function useChatComposerState({ try { uploadedFiles = await uploadFilesToProject(attachedFiles); } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('File upload failed:', error); + const message = + error instanceof Error ? error.message : "Unknown error"; + console.error("File upload failed:", error); setChatMessages((previous) => [ ...previous, { - type: 'error', + type: "error", content: `Failed to upload files: ${message}`, timestamp: new Date(), }, @@ -1065,7 +2144,10 @@ export function useChatComposerState({ messageAttachments = attachedFiles.map((file, index) => { const uploadedFile = uploadedFiles[index]; - const uploadedPath = uploadedFile?.path && typeof uploadedFile.path === 'string' ? uploadedFile.path : undefined; + const uploadedPath = + uploadedFile?.path && typeof uploadedFile.path === "string" + ? uploadedFile.path + : undefined; return { name: file.name, @@ -1078,11 +2160,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: { @@ -1094,7 +2176,9 @@ export function useChatComposerState({ ) => { const sourceFile = attachedFiles[index]; const uploadedPath = - uploadedFile?.path && typeof uploadedFile.path === 'string' ? uploadedFile.path : null; + uploadedFile?.path && typeof uploadedFile.path === "string" + ? uploadedFile.path + : null; if (!sourceFile || !uploadedPath) { return accumulator; @@ -1115,21 +2199,24 @@ export function useChatComposerState({ ); } - const imageFiles = attachedFiles.filter((file) => isImageAttachment(file)); + const imageFiles = attachedFiles.filter((file) => + isImageAttachment(file), + ); if (imageFiles.length > 0) { try { uploadedImages = await uploadPreviewImages(imageFiles); } catch (error) { - console.error('Image preview upload failed:', error); + console.error("Image preview upload failed:", error); } } } const userMessage: ChatMessage = { - type: 'user', + type: "user", content: normalizedInput, images: uploadedImages.length > 0 ? uploadedImages : undefined, - attachments: messageAttachments.length > 0 ? messageAttachments : undefined, + attachments: + messageAttachments.length > 0 ? messageAttachments : undefined, timestamp: new Date(), ...(attachedPrompt ? { attachedPrompt } : {}), }; @@ -1143,7 +2230,7 @@ export function useChatComposerState({ setIsLoading(true); setCanAbortSession(true); setClaudeStatus({ - text: 'Processing', + text: "Processing", tokens: 0, can_interrupt: true, startTime: turnStartTime, @@ -1152,81 +2239,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: { @@ -1240,13 +2323,12 @@ export function useChatComposerState({ telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'gemini') { - console.log('[DEBUG] Sending gemini-command'); + } else if (resolvedProvider === "gemini") { sendMessage({ - type: 'gemini-command', + type: "gemini-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1255,20 +2337,19 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: geminiModel, - permissionMode: effectivePermissionMode, + permissionMode, thinkingMode: geminiThinkingMode, images: uploadedImages.length > 0 ? uploadedImages : undefined, toolsSettings, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'codex') { - console.log('[DEBUG] Sending codex-command'); + } else if (resolvedProvider === "codex") { sendMessage({ - type: 'codex-command', + type: "codex-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1277,20 +2358,23 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: codexModel, - permissionMode: effectivePermissionMode === 'plan' ? 'default' : effectivePermissionMode, - modelReasoningEffort: codexReasoningEffort === 'default' ? undefined : codexReasoningEffort, + permissionMode: + permissionMode === "plan" ? "default" : permissionMode, + modelReasoningEffort: + codexReasoningEffort === "default" + ? undefined + : codexReasoningEffort, attachments: codexAttachmentPayload, images: uploadedImages, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'openrouter') { - console.log('[DEBUG] Sending openrouter-command'); + } else if (resolvedProvider === "openrouter") { sendMessage({ - type: 'openrouter-command', + type: "openrouter-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1299,18 +2383,17 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: openrouterModel, - permissionMode: effectivePermissionMode, + permissionMode, toolsSettings, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); - } else if (provider === 'local') { - console.log('[DEBUG] Sending local-command'); + } else if (resolvedProvider === "local") { sendMessage({ - type: 'local-command', + type: "local-command", command: messageContent, sessionId: effectiveSessionId, options: { @@ -1319,20 +2402,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: { @@ -1345,13 +2429,12 @@ export function useChatComposerState({ telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); } else { - console.log('[DEBUG] Sending claude-command'); sendMessage({ - type: 'claude-command', + type: "claude-command", command: messageContent, options: { projectPath: resolvedProjectPath, @@ -1359,33 +2442,36 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), toolsSettings, - permissionMode: effectivePermissionMode, + permissionMode, model: claudeModel, images: uploadedImages.length > 0 ? uploadedImages : undefined, telemetryEnabled, sessionMode: isNewSession ? newSessionMode : selectedSession?.mode, stageTagKeys: pendingStageTagKeys, - stageTagSource: 'task_context', + stageTagSource: "task_context", }, }); } - setInput(''); - inputValueRef.current = ''; + setInput(""); + inputValueRef.current = ""; setPendingStageTagKeys([]); resetCommandMenuState(); setAttachedFiles([]); setUploadingFiles(new Map()); setFileErrors(new Map()); setIsTextareaExpanded(false); - setThinkingMode('none'); + setThinkingMode("none"); setAttachedPrompt(null); + setSteerMode(false); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; } - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); + if (draftStorageKey) { + safeLocalStorage.removeItem(draftStorageKey); + } }, [ attachedFiles, @@ -1398,34 +2484,39 @@ export function useChatComposerState({ executeCommand, geminiThinkingMode, geminiModel, - openrouterModel, - localModel, - nanoModel, + getToolsSettings, + intakeGreeting, isLoading, + localModel, + newSessionMode, onSessionActive, + onSessionProcessing, + openrouterModel, + pendingStageTagKeys, pendingViewSessionRef, permissionMode, + processingSessions, provider, resetCommandMenuState, + resolveSessionContext, scrollToBottom, selectedProject, selectedSession?.id, + selectedSession?.mode, sendMessage, setCanAbortSession, setChatMessages, setClaudeStatus, setIsLoading, setIsUserScrolledUp, - pendingStageTagKeys, slashCommands, - thinkingMode, + steerMode, t, - intakeGreeting, + thinkingMode, uploadFilesToProject, uploadPreviewImages, ], ); - useEffect(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]); @@ -1435,16 +2526,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) { @@ -1461,21 +2552,20 @@ export function useChatComposerState({ } textareaRef.current.focus(); - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; const cursor = draft.length; textareaRef.current.setSelectionRange(cursor, cursor); - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); - setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2); + const lineHeight = parseInt( + window.getComputedStyle(textareaRef.current).lineHeight, + ); + setIsTextareaExpanded( + textareaRef.current.scrollHeight > lineHeight * 2, + ); }, 0); }; const applyQueuedDraft = () => { - const skillDraft = consumeSkillCommandDraft(); - if (skillDraft) { - applyDraft(skillDraft); - return; - } const wqDraft = consumeWorkspaceQaDraft(selectedProject.name); if (wqDraft) { applyDraft(wqDraft); @@ -1488,14 +2578,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 } })(); } @@ -1514,33 +2608,33 @@ export function useChatComposerState({ window.addEventListener(WORKSPACE_QA_DRAFT_EVENT, handleQueuedDraft); window.addEventListener(REFERENCE_CHAT_DRAFT_EVENT, handleQueuedDraft); - window.addEventListener(SKILL_COMMAND_DRAFT_EVENT, handleQueuedDraft); return () => { window.removeEventListener(WORKSPACE_QA_DRAFT_EVENT, handleQueuedDraft); window.removeEventListener(REFERENCE_CHAT_DRAFT_EVENT, handleQueuedDraft); - window.removeEventListener(SKILL_COMMAND_DRAFT_EVENT, handleQueuedDraft); }; }, [selectedProject?.name, setInput]); useEffect(() => { - if (!selectedProject) { + if (!selectedProject || !draftStorageKey) { return; } - if (input !== '') { - safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input); + if (input !== "") { + safeLocalStorage.setItem(draftStorageKey, input); } else { - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); + safeLocalStorage.removeItem(draftStorageKey); } - }, [input, selectedProject]); + }, [draftStorageKey, input, selectedProject]); useEffect(() => { if (!textareaRef.current) { return; } // Re-run when input changes so restored drafts get the same autosize behavior as typed text. - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); + const lineHeight = parseInt( + window.getComputedStyle(textareaRef.current).lineHeight, + ); const expanded = textareaRef.current.scrollHeight > lineHeight * 2; setIsTextareaExpanded(expanded); }, [input]); @@ -1549,7 +2643,7 @@ export function useChatComposerState({ if (!textareaRef.current || input.trim()) { return; } - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; setIsTextareaExpanded(false); }, [input]); @@ -1564,7 +2658,7 @@ export function useChatComposerState({ if (!newValue.trim()) { setPendingStageTagKeys([]); - event.target.style.height = 'auto'; + event.target.style.height = "auto"; setIsTextareaExpanded(false); resetCommandMenuState(); return; @@ -1575,6 +2669,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)) { @@ -1585,21 +2707,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); } @@ -1610,6 +2740,8 @@ export function useChatComposerState({ handleCommandMenuKeyDown, handleFileMentionsKeyDown, handleSubmit, + isCodexQueueShortcutActive, + provider, sendByCtrlEnter, showCommandMenu, showFileDropdown, @@ -1626,7 +2758,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); @@ -1638,15 +2770,15 @@ export function useChatComposerState({ ); const handleClearInput = useCallback(() => { - setInput(''); - inputValueRef.current = ''; + setInput(""); + inputValueRef.current = ""; setPendingStageTagKeys([]); setAttachedFiles([]); setUploadingFiles(new Map()); setFileErrors(new Map()); resetCommandMenuState(); if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.focus(); } setIsTextareaExpanded(false); @@ -1669,38 +2801,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, }); @@ -1715,7 +2871,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()) { @@ -1731,10 +2902,14 @@ export function useChatComposerState({ return; } - textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); - setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2); + const lineHeight = parseInt( + window.getComputedStyle(textareaRef.current).lineHeight, + ); + setIsTextareaExpanded( + textareaRef.current.scrollHeight > lineHeight * 2, + ); }, 0); return newInput; @@ -1743,7 +2918,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); @@ -1754,7 +2929,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); @@ -1764,7 +2944,7 @@ export function useChatComposerState({ validIds.forEach((requestId) => { sendMessage({ - type: 'claude-permission-response', + type: "claude-permission-response", requestId, allow: Boolean(decision?.allow), updatedInput: decision?.updatedInput, @@ -1774,12 +2954,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; } @@ -1789,14 +2973,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); @@ -1817,6 +3008,8 @@ export function useChatComposerState({ textareaRef, inputHighlightRef, isTextareaExpanded, + steerMode, + setSteerMode, thinkingMode, setThinkingMode, codexReasoningEffort, @@ -1861,9 +3054,20 @@ export function useChatComposerState({ isInputFocused, intakeGreeting, setIntakeGreeting, - setPendingStageTagKeys, - submitProgrammaticInput, btwOverlay, closeBtwOverlay, + setPendingStageTagKeys, + submitProgrammaticInput, + activeQueueSessionId, + activeQueuedTurns, + isActiveQueuePaused, + removeQueuedCodexTurn, + promoteQueuedCodexTurnToSteer, + resumeQueuedCodexTurns, + handleCodexTurnStarted, + handleCodexTurnSettled, + handleCodexSessionIdResolved, + handleCodexSessionBusy, + handleCodexSessionStatusUpdate, }; } diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 72bf8809..fd061920 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,22 +1,39 @@ -import { useEffect, useRef } from 'react'; -import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { useEffect, useRef } from "react"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { buildAssistantMessages, decodeHtmlEntities, formatUsageLimitText, unescapeWithMathProtection, -} from '../utils/chatFormatting'; -import { parseAskUserAnswers, mergeAnswersIntoToolInput } from '../utils/messageTransforms'; +} from "../utils/chatFormatting"; import { + parseAskUserAnswers, + mergeAnswersIntoToolInput, +} from "../utils/messageTransforms"; +import { + buildChatMessagesStorageKey, + clearScopedPendingSessionId, clearSessionTimerStart, moveSessionTimerStart, persistSessionTimerStart, + persistScopedPendingSessionId, + persistScopedProviderSessionId, + readScopedPendingSessionId, safeLocalStorage, -} from '../utils/chatStorage'; -import { RESUMING_STATUS_TEXT } from '../types/types'; -import i18n from '../../../i18n/config'; -import type { ChatMessage, PendingPermissionRequest } from '../types/types'; -import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +} from "../utils/chatStorage"; +import { + emitSessionFilterDebugLog, + syncSessionFilterDebugSetting, +} from "../utils/sessionFilterDebug"; +import { RESUMING_STATUS_TEXT } from "../types/types"; +import i18n from "../../../i18n/config"; +import type { ChatMessage, PendingPermissionRequest } from "../types/types"; +import type { + Project, + ProjectSession, + SessionProvider, +} from "../../../types/app"; +import { isProviderAllowed, normalizeProvider } from "../../../utils/providerPolicy"; type PendingViewSession = { sessionId: string | null; @@ -39,6 +56,8 @@ type LatestChatMessage = { [key: string]: any; }; +const warnedUnknownProviders = new Set(); + interface UseChatRealtimeHandlersArgs { latestMessage: LatestChatMessage | null; provider: SessionProvider; @@ -49,24 +68,64 @@ interface UseChatRealtimeHandlersArgs { setChatMessages: Dispatch>; setIsLoading: (loading: boolean) => void; setCanAbortSession: (canAbort: boolean) => void; - setClaudeStatus: Dispatch>; + setClaudeStatus: Dispatch< + SetStateAction<{ + text: string; + tokens: number; + can_interrupt: boolean; + startTime?: number; + } | null> + >; setStatusTextOverride: Dispatch>; setTokenBudget: (budget: Record | null) => void; setIsSystemSessionChange: (isSystemSessionChange: boolean) => void; - setPendingPermissionRequests: Dispatch>; + setPendingPermissionRequests: Dispatch< + SetStateAction + >; pendingViewSessionRef: MutableRefObject; streamBufferRef: MutableRefObject; streamTimerRef: MutableRefObject; - onSessionInactive?: (sessionId?: string | null) => void; - onSessionProcessing?: (sessionId?: string | null) => void; - onSessionNotProcessing?: (sessionId?: string | null) => void; - onSessionStatusResolved?: (sessionId?: string | null, isProcessing?: boolean) => void; - onReplaceTemporarySession?: (sessionId?: string | null) => void; + onSessionInactive?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionProcessing?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionNotProcessing?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => void; + onSessionStatusResolved?: ( + sessionId?: string | null, + isProcessing?: boolean, + ) => void; + onCodexTurnStarted?: (sessionId?: string | null) => void; + onCodexTurnSettled?: ( + sessionId?: string | null, + outcome?: "complete" | "error" | "aborted", + ) => void; + onCodexSessionBusy?: (sessionId?: string | null) => void; + onCodexSessionIdResolved?: ( + previousSessionId?: string | null, + actualSessionId?: string | null, + ) => void; + onReplaceTemporarySession?: ( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + previousSessionId?: string | null, + ) => void; onNavigateToSession?: ( sessionId: string, sessionProvider?: SessionProvider, targetProjectName?: string, ) => void; + sendMessage?: (message: Record) => void; } const appendStreamingChunk = ( @@ -82,15 +141,25 @@ const appendStreamingChunk = ( const updated = [...previous]; const lastIndex = updated.length - 1; const last = updated[lastIndex]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { + if ( + last && + last.type === "assistant" && + !last.isToolUse && + last.isStreaming + ) { const nextContent = newline ? last.content ? `${last.content}\n${chunk}` : chunk - : `${last.content || ''}${chunk}`; + : `${last.content || ""}${chunk}`; updated[lastIndex] = { ...last, content: nextContent }; } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); + updated.push({ + type: "assistant", + content: chunk, + timestamp: new Date(), + isStreaming: true, + }); } return updated; }); @@ -98,14 +167,21 @@ const appendStreamingChunk = ( // NOTE: unescapeWithMathProtection, formatUsageLimitText, and splitLegacyGeminiThoughtContent // are safe no-ops for non-Gemini text, so no provider guard is needed here. -const finalizeStreamingMessage = (setChatMessages: Dispatch>) => { +const finalizeStreamingMessage = ( + setChatMessages: Dispatch>, +) => { setChatMessages((previous) => { const updated = [...previous]; const lastIndex = updated.length - 1; const last = updated[lastIndex]; - if (last && last.type === 'assistant' && last.isStreaming) { - const normalizedContent = unescapeWithMathProtection(formatUsageLimitText(String(last.content || ''))); - const messages = buildAssistantMessages(normalizedContent, last.timestamp || new Date()); + if (last && last.type === "assistant" && last.isStreaming) { + const normalizedContent = unescapeWithMathProtection( + formatUsageLimitText(String(last.content || "")), + ); + const messages = buildAssistantMessages( + normalizedContent, + last.timestamp || new Date(), + ); updated.splice( lastIndex, 1, @@ -122,12 +198,15 @@ const finalizeStreamingMessage = (setChatMessages: Dispatch { - const normalized = String(value || '').toLowerCase(); - if (!normalized.includes('taskmaster')) { + const normalized = String(value || "").toLowerCase(); + if (!normalized.includes("taskmaster")) { return false; } - return normalized.includes('not installed') || normalized.includes('not configured'); + return ( + normalized.includes("not installed") || + normalized.includes("not configured") + ); }; export function useChatRealtimeHandlers({ @@ -152,13 +231,25 @@ export function useChatRealtimeHandlers({ onSessionProcessing, onSessionNotProcessing, onSessionStatusResolved, + onCodexTurnStarted, + onCodexTurnSettled, + onCodexSessionBusy, + onCodexSessionIdResolved, onReplaceTemporarySession, onNavigateToSession, + sendMessage, }: UseChatRealtimeHandlersArgs) { const lastProcessedMessageRef = useRef(null); + useEffect(() => { + syncSessionFilterDebugSetting(sendMessage); + }, [sendMessage]); + // Helper: Handle structured assistant content - const handleStructuredAssistantMessage = (structuredData: any, rawData: any) => { + const handleStructuredAssistantMessage = ( + structuredData: any, + rawData: any, + ) => { // New assistant message = previous tool execution done; clear override. // If this message contains a new Bash tool_use, it will be re-set below (React batches both updates). setStatusTextOverride(null); @@ -168,11 +259,11 @@ export function useChatRealtimeHandlers({ const childToolUpdates: { parentId: string; child: any }[] = []; structuredData.content.forEach((part: any) => { - if (part.type === 'thinking' || part.type === 'reasoning') { - const thinkingText = part.thinking || part.reasoning || part.text || ''; + if (part.type === "thinking" || part.type === "reasoning") { + const thinkingText = part.thinking || part.reasoning || part.text || ""; if (thinkingText.trim()) { newMessages.push({ - type: 'assistant', + type: "assistant", content: unescapeWithMathProtection(thinkingText), timestamp: new Date(), isThinking: true, @@ -182,12 +273,12 @@ export function useChatRealtimeHandlers({ return; } - if (part.type === 'tool_use') { - if (['Bash', 'run_shell_command'].includes(part.name)) { + if (part.type === "tool_use") { + if (["Bash", "run_shell_command"].includes(part.name)) { // Set running code status when command starts - setStatusTextOverride(i18n.t('chat:status.runningCode')); + setStatusTextOverride(i18n.t("chat:status.runningCode")); } - const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ''; + const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ""; if (parentToolUseId) { childToolUpdates.push({ @@ -203,10 +294,10 @@ export function useChatRealtimeHandlers({ return; } - const isSubagentContainer = part.name === 'Task'; + const isSubagentContainer = part.name === "Task"; newMessages.push({ - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, toolName: part.name, @@ -221,7 +312,7 @@ export function useChatRealtimeHandlers({ return; } - if (part.type === 'text' && part.text?.trim()) { + if (part.type === "text" && part.text?.trim()) { let content = decodeHtmlEntities(part.text); content = formatUsageLimitText(content); newMessages.push(...buildAssistantMessages(content, new Date())); @@ -234,7 +325,9 @@ export function useChatRealtimeHandlers({ if (childToolUpdates.length > 0) { updated = updated.map((message) => { if (!message.isSubagentContainer) return message; - const updates = childToolUpdates.filter((u) => u.parentId === message.toolId); + const updates = childToolUpdates.filter( + (u) => u.parentId === message.toolId, + ); if (updates.length === 0) return message; const existingChildren = message.subagentState?.childTools || []; const newChildren = updates.map((u) => u.child); @@ -242,7 +335,8 @@ export function useChatRealtimeHandlers({ ...message, subagentState: { childTools: [...existingChildren, ...newChildren], - currentToolIndex: existingChildren.length + newChildren.length - 1, + currentToolIndex: + existingChildren.length + newChildren.length - 1, isComplete: false, }, }; @@ -270,23 +364,28 @@ export function useChatRealtimeHandlers({ // Helper: Handle user tool results const handleUserToolResults = (structuredData: any, rawData: any) => { const parentToolUseId = rawData?.parentToolUseId; - const toolResults = structuredData.content.filter((part: any) => part.type === 'tool_result'); - const textParts = structuredData.content.filter((part: any) => part.type === 'text'); + const toolResults = structuredData.content.filter( + (part: any) => part.type === "tool_result", + ); + const textParts = structuredData.content.filter( + (part: any) => part.type === "text", + ); if (textParts.length > 0) { - const textContent = textParts.map((p: any) => p.text || '').join('\n'); + const textContent = textParts.map((p: any) => p.text || "").join("\n"); const isSkillText = - textContent.includes('Base directory for this skill:') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - textContent.startsWith('') || - (toolResults.length > 0 && !textContent.startsWith('')); + textContent.includes("Base directory for this skill:") || + textContent.startsWith("") || + textContent.startsWith("") || + textContent.startsWith("") || + textContent.startsWith("") || + (toolResults.length > 0 && + !textContent.startsWith("")); if (isSkillText && textContent.trim()) { setChatMessages((previous) => [ ...previous, { - type: 'user', + type: "user", content: textContent, timestamp: new Date(), isSkillContent: true, @@ -302,20 +401,26 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => previous.map((message) => { for (const part of toolResults) { - if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) { - const updatedChildren = message.subagentState!.childTools.map((child: any) => { - if (child.toolId === part.tool_use_id) { - return { - ...child, - toolResult: { - content: part.content, - isError: part.is_error, - timestamp: new Date(), - }, - }; - } - return child; - }); + if ( + parentToolUseId && + message.toolId === parentToolUseId && + message.isSubagentContainer + ) { + const updatedChildren = message.subagentState!.childTools.map( + (child: any) => { + if (child.toolId === part.tool_use_id) { + return { + ...child, + toolResult: { + content: part.content, + isError: part.is_error, + timestamp: new Date(), + }, + }; + } + return child; + }, + ); if (updatedChildren !== message.subagentState!.childTools) { return { ...message, @@ -336,8 +441,11 @@ export function useChatRealtimeHandlers({ timestamp: new Date(), }, }; - if (message.toolName === 'AskUserQuestion' && part.content) { - const resultStr = typeof part.content === 'string' ? part.content : JSON.stringify(part.content); + if (message.toolName === "AskUserQuestion" && part.content) { + const resultStr = + typeof part.content === "string" + ? part.content + : JSON.stringify(part.content); const parsedAnswers = parseAskUserAnswers(resultStr); if (parsedAnswers) { const inputStr = typeof message.toolInput === 'string' @@ -367,91 +475,393 @@ 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); - onSessionInactive?.(sessionId); - onSessionNotProcessing?.(sessionId); - onSessionStatusResolved?.(sessionId, false); + notifySessionCompleted(sessionId, latestMessageProvider, latestMessageProjectName); + }; + + const getLifecycleSessionIds = () => { + const ids: string[] = []; + if (latestMessage.sessionId) { + ids.push(latestMessage.sessionId); + } + + if ( + latestMessage.actualSessionId && + latestMessage.actualSessionId !== latestMessage.sessionId + ) { + ids.push(latestMessage.actualSessionId); + } + + return [...new Set(ids)]; }; - const persistStartTime = (startTime?: number | null, ...sessionIds: Array) => { + const persistStartTime = ( + startTime?: number | null, + ...sessionIds: Array + ) => { if (!Number.isFinite(startTime)) { return; } - const targetSessionId = sessionIds.find((sessionId): sessionId is string => typeof sessionId === 'string' && sessionId.length > 0); + const targetSessionId = sessionIds.find( + (sessionId): sessionId is string => + typeof sessionId === "string" && sessionId.length > 0, + ); if (!targetSessionId) { return; } @@ -459,7 +869,10 @@ export function useChatRealtimeHandlers({ persistSessionTimerStart(targetSessionId, startTime); }; - const syncClaudeStatusStartTime = (startTime?: number | null, fallbackText = 'Processing') => { + const syncClaudeStatusStartTime = ( + startTime?: number | null, + fallbackText = "Processing", + ) => { if (!Number.isFinite(startTime)) { return; } @@ -469,7 +882,8 @@ export function useChatRealtimeHandlers({ setClaudeStatus((prev) => ({ text: prev?.text || fallbackText, tokens: prev?.tokens || 0, - can_interrupt: prev?.can_interrupt !== undefined ? prev.can_interrupt : true, + can_interrupt: + prev?.can_interrupt !== undefined ? prev.can_interrupt : true, startTime: normalizedStartTime, })); }; @@ -487,93 +901,388 @@ export function useChatRealtimeHandlers({ streamTimerRef.current = null; } const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; appendStreamingChunk(setChatMessages, chunk, false); finalizeStreamingMessage(setChatMessages); }; - const markSessionsAsCompleted = (...sessionIds: Array) => { - const normalizedSessionIds = sessionIds.filter((id): id is string => typeof id === 'string' && id.length > 0); + const markSessionsAsCompleted = ( + ...sessionIds: Array + ) => { + const normalizedSessionIds = sessionIds.filter( + (id): id is string => typeof id === "string" && id.length > 0, + ); normalizedSessionIds.forEach((sessionId) => { clearSessionTimerStart(sessionId); - onSessionInactive?.(sessionId); - onSessionNotProcessing?.(sessionId); - onSessionStatusResolved?.(sessionId, false); + notifySessionCompleted( + sessionId, + latestMessageProvider, + latestMessageProjectName, + ); }); }; if (!shouldBypassSessionFilter) { if (!activeViewSessionId) { - if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { - handleBackgroundLifecycle(latestMessage.sessionId); + if (lifecycleMessageTypes.has(String(latestMessage.type))) { + getLifecycleSessionIds().forEach((sessionId) => { + handleBackgroundLifecycle(sessionId); + }); } if (!isUnscopedError) { + logFilterDecision("dropped:no-active-view-session"); return; } } - if (!latestMessage.sessionId && !isUnscopedError) { + if (!routedMessageSessionId && !isUnscopedError) { + logFilterDecision("dropped:missing-session-id"); + return; + } + + if (routedMessageSessionId && activeViewSessionId && routedMessageSessionId !== activeViewSessionId) { + if (lifecycleMessageTypes.has(String(latestMessage.type))) { + getLifecycleSessionIds().forEach((sessionId) => { + handleBackgroundLifecycle(sessionId); + }); + } + logFilterDecision("dropped:session-id-mismatch", { + expectedSessionId: activeViewSessionId, + actualSessionId: routedMessageSessionId, + }); + return; + } + + if (latestMessageProvider !== activeViewProvider) { + if (lifecycleMessageTypes.has(String(latestMessage.type))) { + getLifecycleSessionIds().forEach((sessionId) => { + handleBackgroundLifecycle(sessionId); + }); + } + logFilterDecision("dropped:provider-mismatch", { + expectedProvider: activeViewProvider, + actualProvider: latestMessageProvider, + }); return; } - if (latestMessage.sessionId !== activeViewSessionId) { - if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { - handleBackgroundLifecycle(latestMessage.sessionId); + if ( + activeViewProjectName && + latestMessageProjectName && + activeViewProjectName !== latestMessageProjectName + ) { + if (lifecycleMessageTypes.has(String(latestMessage.type))) { + getLifecycleSessionIds().forEach((sessionId) => { + handleBackgroundLifecycle(sessionId); + }); } + logFilterDecision("dropped:project-mismatch", { + expectedProjectName: activeViewProjectName, + actualProjectName: latestMessageProjectName, + }); return; } } switch (latestMessage.type) { - case 'session-created': - if (latestMessage.sessionId && (!currentSessionId || currentSessionId.startsWith('new-session-'))) { + case "session-accepted": { + const acceptedSessionId = + routedMessageSessionId || + pendingViewSessionRef.current?.sessionId || + currentSessionId || + selectedSession?.id || + null; + const acceptedAt = Number.isFinite(latestMessage.acceptedAt) + ? (latestMessage.acceptedAt as number) + : Date.now(); + const acceptedProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const acceptedProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const isCurrentSession = + !acceptedSessionId || + isMessageInActiveScope( + acceptedSessionId, + acceptedProvider, + acceptedProjectName, + ); + + if (acceptedSessionId) { + persistStartTime( + acceptedAt, + acceptedSessionId, + currentSessionId, + selectedSession?.id, + ); + notifySessionProcessing( + acceptedSessionId, + acceptedProvider, + acceptedProjectName, + ); + } + + if (isCurrentSession) { + setIsLoading(true); + setCanAbortSession(true); + syncClaudeStatusStartTime(acceptedAt, "Processing"); + } + break; + } + + case "session-busy": { + const busySessionId = + routedMessageSessionId || + pendingViewSessionRef.current?.sessionId || + currentSessionId || + selectedSession?.id || + null; + const busyAt = Number.isFinite(latestMessage.reportedAt) + ? (latestMessage.reportedAt as number) + : Date.now(); + const busyProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const busyProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const isCurrentSession = + !busySessionId || + isMessageInActiveScope(busySessionId, busyProvider, busyProjectName); + + if (busySessionId) { + persistStartTime( + busyAt, + busySessionId, + currentSessionId, + selectedSession?.id, + ); + notifySessionProcessing(busySessionId, busyProvider, busyProjectName); + } + + if (busyProvider === "codex") { + onCodexSessionBusy?.(busySessionId); + } + + if (isCurrentSession) { + const busyMessage = String( + latestMessage.message || + "Session is busy. Waiting for the current turn to finish.", + ); + setIsLoading(true); + setCanAbortSession(true); + setStatusTextOverride(busyMessage); + setChatMessages((previous) => { + const lastMessage = previous[previous.length - 1]; + if ( + lastMessage && + lastMessage.type === "assistant" && + String(lastMessage.content || "") === busyMessage + ) { + return previous; + } + return [ + ...previous, + { + type: "assistant", + content: busyMessage, + timestamp: new Date(), + }, + ]; + }); + } + break; + } + + case "session-state-changed": { + const stateSessionId = + typeof routedMessageSessionId === "string" + ? routedMessageSessionId + : null; + if (!stateSessionId) { + break; + } + + const state = String(latestMessage.state || "").toLowerCase(); + const stateProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const stateProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const isCurrentSession = + isMessageInActiveScope( + stateSessionId, + stateProvider, + stateProjectName, + ); + const isProcessingState = + state === "running" || + state === "queued" || + state === "in_progress" || + state === "waiting_user"; + const isTerminalState = + state === "completed" || + state === "failed" || + state === "aborted" || + state === "error" || + state === "idle"; + + if (isProcessingState) { + notifySessionProcessing(stateSessionId, stateProvider, stateProjectName); + if (isCurrentSession) { + setIsLoading(true); + setCanAbortSession(true); + } + break; + } + + if (isTerminalState) { + clearSessionTimerStart(stateSessionId); + notifySessionCompleted(stateSessionId, stateProvider, stateProjectName); + if (isCurrentSession) { + clearLoadingIndicators(); + } + } + break; + } + + case "session-created": + if ( + latestMessage.sessionId && + (!currentSessionId || currentSessionId.startsWith("new-session-")) + ) { const createdSessionProvider = - (latestMessage.provider as SessionProvider | undefined) || provider; + resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const explicitProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : null, + ); + const createdProjectName = + explicitProjectName + || (pendingViewSessionRef.current ? selectedProject?.name || null : null); const pendingStartTime = pendingViewSessionRef.current?.startedAt; - const temporarySessionId = currentSessionId?.startsWith('new-session-') ? currentSessionId : null; + const pendingTemporarySessionId = pendingViewSessionRef.current + ?.sessionId?.startsWith("new-session-") + ? pendingViewSessionRef.current.sessionId + : null; + const temporarySessionId = currentSessionId?.startsWith( + "new-session-", + ) + ? currentSessionId + : pendingTemporarySessionId; if (temporarySessionId) { moveSessionTimerStart(temporarySessionId, latestMessage.sessionId); + if (createdSessionProvider === "codex") { + onCodexSessionIdResolved?.( + temporarySessionId, + latestMessage.sessionId, + ); + } } persistStartTime( - typeof latestMessage.startTime === 'number' ? latestMessage.startTime : pendingStartTime, + typeof latestMessage.startTime === "number" + ? latestMessage.startTime + : pendingStartTime, latestMessage.sessionId, ); - if (selectedProject && latestMessage.mode) { - safeLocalStorage.setItem(`session_mode_${selectedProject.name}_${latestMessage.sessionId}`, String(latestMessage.mode)); + if (createdProjectName && latestMessage.mode) { + safeLocalStorage.setItem( + `session_mode_${createdProjectName}_${latestMessage.sessionId}`, + String(latestMessage.mode), + ); } - sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); - if ((latestMessage as any).provider === 'gemini') { - sessionStorage.setItem('geminiSessionId', latestMessage.sessionId); - } else if (latestMessage.model) { - sessionStorage.setItem('cursorSessionId', latestMessage.sessionId); + persistScopedPendingSessionId( + createdProjectName, + createdSessionProvider, + latestMessage.sessionId, + ); + if ( + createdSessionProvider === "gemini" || + createdSessionProvider === "cursor" + ) { + persistScopedProviderSessionId( + createdProjectName, + createdSessionProvider, + latestMessage.sessionId, + ); } - if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { + if ( + pendingViewSessionRef.current && + (!pendingViewSessionRef.current.sessionId || + pendingViewSessionRef.current.sessionId.startsWith( + "new-session-", + )) + ) { pendingViewSessionRef.current.sessionId = latestMessage.sessionId; } setIsSystemSessionChange(true); - onReplaceTemporarySession?.(latestMessage.sessionId); - onNavigateToSession?.(latestMessage.sessionId, createdSessionProvider, selectedProject?.name); + onReplaceTemporarySession?.( + latestMessage.sessionId, + createdSessionProvider, + createdProjectName, + temporarySessionId, + ); + if (createdProjectName || pendingViewSessionRef.current) { + onNavigateToSession?.( + latestMessage.sessionId, + createdSessionProvider, + createdProjectName || undefined, + ); + } setPendingPermissionRequests((previous) => previous.map((request) => - request.sessionId ? request : { ...request, sessionId: latestMessage.sessionId }, + request.sessionId + ? request + : { ...request, sessionId: latestMessage.sessionId }, ), ); } break; - case 'token-budget': + case "token-budget": if (latestMessage.data) { setTokenBudget(latestMessage.data); } break; - case 'claude-response': { - if (messageData && typeof messageData === 'object' && messageData.type) { + case "claude-response": { + if ( + messageData && + typeof messageData === "object" && + messageData.type + ) { if (Number.isFinite(messageData.startTime)) { - persistStartTime(messageData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + messageData.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(messageData.startTime); } - if (messageData.type === 'content_block_delta' && messageData.delta?.text) { + if ( + messageData.type === "content_block_delta" && + messageData.delta?.text + ) { setIsLoading(true); setStatusTextOverride(null); const decodedText = decodeHtmlEntities(messageData.delta.text); @@ -581,54 +1290,91 @@ export function useChatRealtimeHandlers({ if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, false); }, 30); } return; } - if (messageData.type === 'content_block_stop') { + if (messageData.type === "content_block_stop") { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; appendStreamingChunk(setChatMessages, chunk, false); finalizeStreamingMessage(setChatMessages); return; } } - if (isClaudeSystemInit && structuredMessageData?.session_id && isSystemInitForView) { - if (!currentSessionId || structuredMessageData.session_id !== currentSessionId) { - console.log('Claude CLI session duplication or new init detected'); + if ( + isClaudeSystemInit && + structuredMessageData?.session_id && + isSystemInitForView + ) { + if ( + !currentSessionId || + structuredMessageData.session_id !== currentSessionId + ) { setIsSystemSessionChange(true); - onNavigateToSession?.(structuredMessageData.session_id, 'claude', selectedProject?.name); + onNavigateToSession?.( + structuredMessageData.session_id, + "claude", + latestMessageProjectName || undefined, + ); return; } } - if (structuredMessageData && Array.isArray(structuredMessageData.content) && structuredMessageData.role === 'assistant') { - handleStructuredAssistantMessage(structuredMessageData, rawStructuredData); - } else if (structuredMessageData && structuredMessageData.role === 'assistant' && typeof structuredMessageData.content === 'string' && structuredMessageData.content.trim()) { + if ( + structuredMessageData && + Array.isArray(structuredMessageData.content) && + structuredMessageData.role === "assistant" + ) { + handleStructuredAssistantMessage( + structuredMessageData, + rawStructuredData, + ); + } else if ( + structuredMessageData && + structuredMessageData.role === "assistant" && + typeof structuredMessageData.content === "string" && + structuredMessageData.content.trim() + ) { handleSimpleAssistantMessage(structuredMessageData); } - if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) { + if ( + structuredMessageData?.role === "user" && + Array.isArray(structuredMessageData.content) + ) { handleUserToolResults(structuredMessageData, rawStructuredData); } break; } - case 'gemini-response': { - if (messageData && typeof messageData === 'object' && messageData.type) { + case "gemini-response": { + if ( + messageData && + typeof messageData === "object" && + messageData.type + ) { if (Number.isFinite(messageData.startTime)) { - persistStartTime(messageData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + messageData.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(messageData.startTime); } - if (messageData.type === 'content_block_delta' && messageData.delta?.text) { + if ( + messageData.type === "content_block_delta" && + messageData.delta?.text + ) { setIsLoading(true); setStatusTextOverride(null); const decodedText = decodeHtmlEntities(messageData.delta.text); @@ -636,57 +1382,87 @@ export function useChatRealtimeHandlers({ if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, false); }, 30); } return; } - if (messageData.type === 'content_block_stop') { + if (messageData.type === "content_block_stop") { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; appendStreamingChunk(setChatMessages, chunk, false); finalizeStreamingMessage(setChatMessages); return; } } - if (isGeminiSystemInit && structuredMessageData?.session_id && isSystemInitForView) { - if (!currentSessionId || structuredMessageData.session_id !== currentSessionId) { - console.log('Gemini CLI session init detected'); + if ( + isGeminiSystemInit && + structuredMessageData?.session_id && + isSystemInitForView + ) { + if ( + !currentSessionId || + structuredMessageData.session_id !== currentSessionId + ) { setIsSystemSessionChange(true); - onNavigateToSession?.(structuredMessageData.session_id, 'gemini', selectedProject?.name); + onNavigateToSession?.( + structuredMessageData.session_id, + "gemini", + latestMessageProjectName || undefined, + ); return; } } - if (structuredMessageData && Array.isArray(structuredMessageData.content) && structuredMessageData.role === 'assistant') { - handleStructuredAssistantMessage(structuredMessageData, rawStructuredData); - } else if (structuredMessageData && structuredMessageData.role === 'assistant' && typeof structuredMessageData.content === 'string' && structuredMessageData.content.trim()) { + if ( + structuredMessageData && + Array.isArray(structuredMessageData.content) && + structuredMessageData.role === "assistant" + ) { + handleStructuredAssistantMessage( + structuredMessageData, + rawStructuredData, + ); + } else if ( + structuredMessageData && + structuredMessageData.role === "assistant" && + typeof structuredMessageData.content === "string" && + structuredMessageData.content.trim() + ) { handleSimpleAssistantMessage(structuredMessageData); } - if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) { + if ( + structuredMessageData?.role === "user" && + Array.isArray(structuredMessageData.content) + ) { handleUserToolResults(structuredMessageData, rawStructuredData); } break; } - case 'localgpu-response': - case 'openrouter-response': { + case "localgpu-response": + case "openrouter-response": { const orData = latestMessage.data; - if (orData && typeof orData === 'object') { + if (orData && typeof orData === "object") { if (Number.isFinite(orData.startTime)) { - persistStartTime(orData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + orData.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(orData.startTime); } - if (orData.type === 'assistant_message' && orData.message?.content) { + if (orData.type === "assistant_message" && orData.message?.content) { setIsLoading(true); setStatusTextOverride(null); const text = orData.message.content; @@ -694,7 +1470,7 @@ export function useChatRealtimeHandlers({ if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, false); }, 30); @@ -702,28 +1478,32 @@ export function useChatRealtimeHandlers({ return; } - if (orData.type === 'structured_turn' && orData.message) { + if (orData.type === "structured_turn" && orData.message) { flushAndFinalizePendingStream(); handleStructuredAssistantMessage(orData.message, orData); return; } - if (orData.type === 'structured_result' && orData.message) { + if (orData.type === "structured_result" && orData.message) { handleUserToolResults(orData.message, orData); return; } - if (orData.type === 'tool_use') { + if (orData.type === "tool_use") { flushAndFinalizePendingStream(); - if (['Bash', 'bash', 'run_shell_command'].includes(orData.toolName)) { - setStatusTextOverride(i18n.t('chat:status.runningCode')); + if ( + ["Bash", "bash", "run_shell_command"].includes(orData.toolName) + ) { + setStatusTextOverride(i18n.t("chat:status.runningCode")); } - const toolInput = orData.toolInput ? JSON.stringify(orData.toolInput, null, 2) : ''; + const toolInput = orData.toolInput + ? JSON.stringify(orData.toolInput, null, 2) + : ""; setChatMessages((prev) => [ ...prev, { - type: 'assistant' as const, - content: '', + type: "assistant" as const, + content: "", timestamp: new Date(), isToolUse: true, toolName: orData.toolName, @@ -735,12 +1515,15 @@ export function useChatRealtimeHandlers({ return; } - if (orData.type === 'tool_result') { + if (orData.type === "tool_result") { setStatusTextOverride(null); setChatMessages((prev) => { const updated = [...prev]; for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].isToolUse && updated[i].toolId === orData.toolCallId) { + if ( + updated[i].isToolUse && + updated[i].toolId === orData.toolCallId + ) { updated[i] = { ...updated[i], toolResult: { @@ -760,14 +1543,16 @@ export function useChatRealtimeHandlers({ break; } - case 'claude-output': { - const cleaned = String(latestMessage.data || ''); + case "claude-output": { + const cleaned = String(latestMessage.data || ""); if (cleaned.trim()) { - streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned; + streamBufferRef.current += streamBufferRef.current + ? `\n${cleaned}` + : cleaned; if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, true); }, 30); @@ -776,30 +1561,51 @@ export function useChatRealtimeHandlers({ break; } - case 'claude-complete': - case 'gemini-complete': - case 'openrouter-complete': - case 'localgpu-complete': { - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - const completedSessionId = latestMessage.sessionId || currentSessionId || pendingSessionId; + case "claude-complete": + case "cursor-complete": + case "gemini-complete": + case "openrouter-complete": + case "localgpu-complete": { + const pendingSessionId = readScopedPendingSessionId( + latestMessageProjectName, + latestMessageProvider, + ); + const completedSessionId = + latestMessage.sessionId || currentSessionId || pendingSessionId; flushAndFinalizePendingStream(); clearLoadingIndicators(); - markSessionsAsCompleted(completedSessionId, currentSessionId, selectedSession?.id, pendingSessionId); - if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { + markSessionsAsCompleted( + completedSessionId, + currentSessionId, + selectedSession?.id, + pendingSessionId, + ); + if ( + pendingSessionId && + !currentSessionId && + latestMessage.exitCode === 0 + ) { setCurrentSessionId(pendingSessionId); - sessionStorage.removeItem('pendingSessionId'); + clearScopedPendingSessionId( + latestMessageProjectName, + latestMessageProvider, + ); } - if (selectedProject && latestMessage.exitCode === 0) { - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); + if (latestMessage.exitCode === 0) { + clearScopedMessageCache( + completedSessionId || pendingSessionId, + latestMessageProvider, + latestMessageProjectName, + ); } setPendingPermissionRequests([]); break; } - case 'claude-error': - case 'gemini-error': - case 'openrouter-error': - case 'localgpu-error': { + case "claude-error": + case "gemini-error": + case "openrouter-error": + case "localgpu-error": { if (isLegacyTaskMasterInstallError(latestMessage.error)) { break; } @@ -811,28 +1617,45 @@ export function useChatRealtimeHandlers({ null; flushAndFinalizePendingStream(); clearLoadingIndicators(); - markSessionsAsCompleted(erroredSessionId, currentSessionId, selectedSession?.id); - // Clear pendingSessionId for the errored session (not all sessions — other tabs may be active) - if (typeof window !== 'undefined') { - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - if (pendingSessionId && (!erroredSessionId || pendingSessionId === erroredSessionId)) { - sessionStorage.removeItem('pendingSessionId'); - } + markSessionsAsCompleted( + erroredSessionId, + currentSessionId, + selectedSession?.id, + ); + // Clear pendingSessionId for the errored session (not all sessions 鈥?other tabs may be active) + const pendingSessionId = readScopedPendingSessionId( + latestMessageProjectName, + latestMessageProvider, + ); + if ( + pendingSessionId && + (!erroredSessionId || pendingSessionId === erroredSessionId) + ) { + clearScopedPendingSessionId( + latestMessageProjectName, + latestMessageProvider, + ); } setPendingPermissionRequests([]); - const details = typeof latestMessage.details === 'string' ? latestMessage.details.trim() : ''; + const details = + typeof latestMessage.details === "string" + ? latestMessage.details.trim() + : ""; const errorContent = details ? `Error: ${latestMessage.error}\n\n
Technical details\n\n\`\`\`text\n${details.slice(0, 8000)}\n\`\`\`\n
` : `Error: ${latestMessage.error}`; setChatMessages((previous) => { const last = previous[previous.length - 1]; - if (last?.type === 'error' && String(last.content || '') === errorContent) { + if ( + last?.type === "error" && + String(last.content || "") === errorContent + ) { return previous; } return [ ...previous, { - type: 'error', + type: "error", content: errorContent, timestamp: new Date(), errorType: latestMessage.errorType, @@ -843,27 +1666,39 @@ export function useChatRealtimeHandlers({ break; } - case 'cursor-system': + case "cursor-system": try { const cursorData = latestMessage.data; - if (cursorData && cursorData.type === 'system' && cursorData.subtype === 'init' && cursorData.session_id) { + if ( + cursorData && + cursorData.type === "system" && + cursorData.subtype === "init" && + cursorData.session_id + ) { if (!isSystemInitForView) return; - if (!currentSessionId || cursorData.session_id !== currentSessionId) { + if ( + !currentSessionId || + cursorData.session_id !== currentSessionId + ) { setIsSystemSessionChange(true); - onNavigateToSession?.(cursorData.session_id, 'cursor', selectedProject?.name); + onNavigateToSession?.( + cursorData.session_id, + "cursor", + latestMessageProjectName || undefined, + ); } } } catch (error) { - console.warn('Error handling cursor-system message:', error); + console.warn("Error handling cursor-system message:", error); } break; - case 'cursor-tool-use': + case "cursor-tool-use": setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''}`, + type: "assistant", + content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ""}`, timestamp: new Date(), isToolUse: true, toolName: latestMessage.tool, @@ -872,127 +1707,190 @@ export function useChatRealtimeHandlers({ ]); break; - case 'cursor-error': + case "cursor-error": if (isLegacyTaskMasterInstallError(latestMessage.error)) break; flushAndFinalizePendingStream(); clearLoadingIndicators(); - markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); + markSessionsAsCompleted( + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); setPendingPermissionRequests([]); setChatMessages((previous) => [ ...previous, - { type: 'error', content: `Cursor error: ${latestMessage.error || 'Unknown error'}`, timestamp: new Date(), errorType: latestMessage.errorType, isRetryable: latestMessage.isRetryable === true }, + { + type: "error", + content: `Cursor error: ${latestMessage.error || "Unknown error"}`, + timestamp: new Date(), + errorType: latestMessage.errorType, + isRetryable: latestMessage.isRetryable === true, + }, ]); break; - case 'cursor-result': { - const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; - const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); - + case "cursor-result": { + const cursorCompletedSessionId = + latestMessage.sessionId || currentSessionId; + const pendingCursorSessionId = + readScopedPendingSessionId(latestMessageProjectName, "cursor"); + if (Number.isFinite(latestMessage.startTime)) { - persistStartTime(latestMessage.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + latestMessage.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(latestMessage.startTime); } clearLoadingIndicators(); - markSessionsAsCompleted(cursorCompletedSessionId, currentSessionId, selectedSession?.id, pendingCursorSessionId); + markSessionsAsCompleted( + cursorCompletedSessionId, + currentSessionId, + selectedSession?.id, + pendingCursorSessionId, + ); try { const resultData = latestMessage.data || {}; - const textResult = typeof resultData.result === 'string' ? resultData.result : ''; + const textResult = + typeof resultData.result === "string" ? resultData.result : ""; if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } const pendingChunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; setChatMessages((previous) => { const updated = [...previous]; const lastIndex = updated.length - 1; const last = updated[lastIndex]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - const finalContent = textResult && textResult.trim() ? textResult : `${last.content || ''}${pendingChunk || ''}`; - updated[lastIndex] = { ...last, content: finalContent, isStreaming: false }; + if ( + last && + last.type === "assistant" && + !last.isToolUse && + last.isStreaming + ) { + const finalContent = + textResult && textResult.trim() + ? textResult + : `${last.content || ""}${pendingChunk || ""}`; + updated[lastIndex] = { + ...last, + content: finalContent, + isStreaming: false, + }; } else if (textResult && textResult.trim()) { - updated.push({ type: resultData.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false }); + updated.push({ + type: resultData.is_error ? "error" : "assistant", + content: textResult, + timestamp: new Date(), + isStreaming: false, + }); } return updated; }); } catch (error) { - console.warn('Error handling cursor-result message:', error); + console.warn("Error handling cursor-result message:", error); } - if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) { + if ( + cursorCompletedSessionId && + !currentSessionId && + cursorCompletedSessionId === pendingCursorSessionId + ) { setCurrentSessionId(cursorCompletedSessionId); - sessionStorage.removeItem('pendingSessionId'); - if (window.refreshProjects) setTimeout(() => window.refreshProjects?.(), 500); + clearScopedPendingSessionId(latestMessageProjectName, "cursor"); + if (window.refreshProjects) + setTimeout(() => window.refreshProjects?.(), 500); } break; } - case 'cursor-output': + case "cursor-output": try { if (Number.isFinite(latestMessage.startTime)) { - persistStartTime(latestMessage.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + latestMessage.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(latestMessage.startTime); } setIsLoading(true); - const raw = String(latestMessage.data ?? ''); + const raw = String(latestMessage.data ?? ""); const cleaned = raw - .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') + .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "") .trim(); if (cleaned) { - streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned; + streamBufferRef.current += streamBufferRef.current + ? `\n${cleaned}` + : cleaned; if (!streamTimerRef.current) { streamTimerRef.current = window.setTimeout(() => { const chunk = streamBufferRef.current; - streamBufferRef.current = ''; + streamBufferRef.current = ""; streamTimerRef.current = null; appendStreamingChunk(setChatMessages, chunk, true); }, 100); } } } catch (error) { - console.warn('Error handling cursor-output message:', error); + console.warn("Error handling cursor-output message:", error); } break; - case 'codex-response': { + case "codex-response": { const codexData = latestMessage.data; if (!codexData) break; if (Number.isFinite(codexData.startTime)) { - persistStartTime(codexData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); + persistStartTime( + codexData.startTime, + latestMessage.sessionId, + currentSessionId, + selectedSession?.id, + ); syncClaudeStatusStartTime(codexData.startTime); } setIsLoading(true); - if (codexData.type === 'item') { + if (codexData.type === "item") { const itemId = codexData.itemId; const lifecycle = codexData.lifecycle; // 'started' | 'completed' | 'other' switch (codexData.itemType) { - case 'agent_message': + case "agent_message": if (codexData.message?.content?.trim()) { const content = decodeHtmlEntities(codexData.message.content); // Server marks system prompts; also detect on frontend as fallback - const isSystemPrompt = codexData.isSystemPrompt || + const isSystemPrompt = + codexData.isSystemPrompt || /^#\s+(AGENTS|SKILL|INSTRUCTIONS)/m.test(content) || - content.includes('') || - content.includes('') || + content.includes("") || + content.includes("") || /^#+\s+.*instructions\s+for\s+\//im.test(content) || - (content.includes('Base directory for this skill:') && content.length > 500) || - (content.length > 2000 && /^\d+\)\s/m.test(content) && /\bskill\b/i.test(content)) || - ((content.match(/SKILL\.md\)/g) || []).length >= 3) || - content.includes('### How to use skills') || - content.includes('## How to use skills') || - (content.includes('Trigger rules:') && content.includes('skill') && content.length > 500); + (content.includes("Base directory for this skill:") && + content.length > 500) || + (content.length > 2000 && + /^\d+\)\s/m.test(content) && + /\bskill\b/i.test(content)) || + (content.match(/SKILL\.md\)/g) || []).length >= 3 || + content.includes("### How to use skills") || + content.includes("## How to use skills") || + (content.includes("Trigger rules:") && + content.includes("skill") && + content.length > 500); if (isSystemPrompt) { // Show as collapsed skill content setChatMessages((previous) => [ ...previous, { - type: 'user', + type: "user", content, timestamp: new Date(), isSkillContent: true, @@ -1002,7 +1900,7 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', + type: "assistant", content, timestamp: new Date(), }, @@ -1011,14 +1909,14 @@ export function useChatRealtimeHandlers({ } break; - case 'reasoning': + case "reasoning": // Codex reasoning items are very brief status notes (e.g. "Planning API path inspection") // They add noise without value - skip them entirely for Codex sessions break; - case 'command_execution': - if (lifecycle !== 'completed') { - setStatusTextOverride(i18n.t('chat:status.runningCode')); + case "command_execution": + if (lifecycle !== "completed") { + setStatusTextOverride(i18n.t("chat:status.runningCode")); } else { setStatusTextOverride(null); } @@ -1028,7 +1926,7 @@ export function useChatRealtimeHandlers({ // Wrap command in object format expected by Bash ToolRenderer const bashToolInput = { command: codexData.command }; - if (lifecycle === 'completed' && itemId) { + if (lifecycle === "completed" && itemId) { // Update existing tool message if it was added on 'started' setChatMessages((previous) => { const existingIdx = previous.findIndex( @@ -1038,10 +1936,13 @@ export function useChatRealtimeHandlers({ const updated = [...previous]; updated[existingIdx] = { ...updated[existingIdx], - toolResult: output != null ? { - content: output, - isError: exitCode != null && exitCode !== 0, - } : null, + toolResult: + output != null + ? { + content: output, + isError: exitCode != null && exitCode !== 0, + } + : null, exitCode, }; return updated; @@ -1050,16 +1951,19 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'Bash', + toolName: "Bash", toolInput: bashToolInput, - toolResult: output != null ? { - content: output, - isError: exitCode != null && exitCode !== 0, - } : null, + toolResult: + output != null + ? { + content: output, + isError: exitCode != null && exitCode !== 0, + } + : null, exitCode, codexItemId: itemId, }, @@ -1070,16 +1974,19 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'Bash', + toolName: "Bash", toolInput: bashToolInput, - toolResult: output != null ? { - content: output, - isError: exitCode != null && exitCode !== 0, - } : null, + toolResult: + output != null + ? { + content: output, + isError: exitCode != null && exitCode !== 0, + } + : null, exitCode, codexItemId: itemId, }, @@ -1088,13 +1995,16 @@ export function useChatRealtimeHandlers({ } break; - case 'file_change': + case "file_change": if (codexData.changes?.length > 0) { const changesList = codexData.changes - .map((change: { kind: string; path: string }) => `${change.kind}: ${change.path}`) - .join('\n'); + .map( + (change: { kind: string; path: string }) => + `${change.kind}: ${change.path}`, + ) + .join("\n"); - if (lifecycle === 'completed' && itemId) { + if (lifecycle === "completed" && itemId) { setChatMessages((previous) => { const existingIdx = previous.findIndex( (m) => m.codexItemId === itemId && m.isToolUse, @@ -1114,11 +2024,11 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'FileChanges', + toolName: "FileChanges", toolInput: changesList, toolResult: { content: `Status: ${codexData.status}`, @@ -1132,16 +2042,18 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'FileChanges', + toolName: "FileChanges", toolInput: changesList, - toolResult: codexData.status ? { - content: `Status: ${codexData.status}`, - isError: false, - } : null, + toolResult: codexData.status + ? { + content: `Status: ${codexData.status}`, + isError: false, + } + : null, codexItemId: itemId, }, ]); @@ -1149,14 +2061,17 @@ export function useChatRealtimeHandlers({ } break; - case 'mcp_tool_call': { + case "mcp_tool_call": { const toolResult = codexData.result - ? { content: JSON.stringify(codexData.result, null, 2), isError: false } + ? { + content: JSON.stringify(codexData.result, null, 2), + isError: false, + } : codexData.error?.message - ? { content: codexData.error.message, isError: true } - : null; + ? { content: codexData.error.message, isError: true } + : null; - if (lifecycle === 'completed' && itemId) { + if (lifecycle === "completed" && itemId) { setChatMessages((previous) => { const existingIdx = previous.findIndex( (m) => m.codexItemId === itemId && m.isToolUse, @@ -1172,8 +2087,8 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, toolName: `${codexData.server}:${codexData.tool}`, @@ -1187,8 +2102,8 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, toolName: `${codexData.server}:${codexData.tool}`, @@ -1201,9 +2116,9 @@ export function useChatRealtimeHandlers({ break; } - case 'web_search': { - const query = codexData.query || 'Searching...'; - if (lifecycle === 'completed' && itemId) { + case "web_search": { + const query = codexData.query || "Searching..."; + if (lifecycle === "completed" && itemId) { // Update existing or add new setChatMessages((previous) => { const existingIdx = previous.findIndex( @@ -1216,11 +2131,11 @@ export function useChatRealtimeHandlers({ return [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'WebSearch', + toolName: "WebSearch", toolInput: { command: query }, toolResult: null, codexItemId: itemId, @@ -1231,11 +2146,11 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => [ ...previous, { - type: 'assistant', - content: '', + type: "assistant", + content: "", timestamp: new Date(), isToolUse: true, - toolName: 'WebSearch', + toolName: "WebSearch", toolInput: { command: query }, toolResult: null, codexItemId: itemId, @@ -1245,12 +2160,12 @@ export function useChatRealtimeHandlers({ break; } - case 'error': + case "error": if (codexData.message?.content) { setChatMessages((previous) => [ ...previous, { - type: 'error', + type: "error", content: codexData.message.content, timestamp: new Date(), }, @@ -1259,97 +2174,226 @@ export function useChatRealtimeHandlers({ break; default: - console.log('[Codex] Unhandled item type:', codexData.itemType, codexData); + console.log( + "[Codex] Unhandled item type:", + codexData.itemType, + codexData, + ); } } - if (codexData.type === 'turn_complete' || codexData.type === 'turn_failed') { + if ( + codexData.type === "turn_complete" || + codexData.type === "turn_failed" + ) { clearLoadingIndicators(); - markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); - if (codexData.type === 'turn_failed') { - setChatMessages((previous) => [...previous, { type: 'error', content: codexData.error?.message || 'Turn failed', timestamp: new Date() }]); + markSessionsAsCompleted( + routedMessageSessionId, + currentSessionId, + selectedSession?.id, + ); + if (codexData.type === "turn_failed") { + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: codexData.error?.message || "Turn failed", + timestamp: new Date(), + }, + ]); } } break; } - case 'codex-complete': { - const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); - const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId; - const codexCompletedSessionId = latestMessage.sessionId || currentSessionId || codexPendingSessionId; + case "codex-complete": { + const codexPendingSessionId = + readScopedPendingSessionId(latestMessageProjectName, "codex"); + const codexActualSessionId = + latestMessage.actualSessionId || + codexPendingSessionId || + routedMessageSessionId; + const codexCompletedSessionId = + routedMessageSessionId || currentSessionId || codexPendingSessionId; clearLoadingIndicators(); - markSessionsAsCompleted(codexCompletedSessionId, codexActualSessionId, currentSessionId, selectedSession?.id, codexPendingSessionId); - if (codexPendingSessionId && !currentSessionId) { - setCurrentSessionId(codexActualSessionId); + markSessionsAsCompleted( + codexCompletedSessionId, + codexActualSessionId, + currentSessionId, + selectedSession?.id, + codexPendingSessionId, + ); + + const shouldSyncToActualSessionId = + Boolean(codexActualSessionId) && + codexActualSessionId !== currentSessionId && + ((currentSessionId && currentSessionId.startsWith("new-session-")) || + Boolean(codexPendingSessionId)); + + if (shouldSyncToActualSessionId) { + setCurrentSessionId(codexActualSessionId || null); setIsSystemSessionChange(true); if (codexActualSessionId) { - onNavigateToSession?.(codexActualSessionId, 'codex', selectedProject?.name); + onNavigateToSession?.( + codexActualSessionId, + "codex", + latestMessageProjectName || undefined, + ); } - sessionStorage.removeItem('pendingSessionId'); } - if (selectedProject) safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); + + if (codexPendingSessionId) { + clearScopedPendingSessionId(latestMessageProjectName, "codex"); + } + + clearScopedMessageCache( + codexCompletedSessionId || codexActualSessionId, + "codex", + latestMessageProjectName, + ); break; } - case 'codex-error': + case "codex-error": if (isLegacyTaskMasterInstallError(latestMessage.error)) break; flushAndFinalizePendingStream(); clearLoadingIndicators(); - markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id); + markSessionsAsCompleted( + routedMessageSessionId, + currentSessionId, + selectedSession?.id, + ); setPendingPermissionRequests([]); - setChatMessages((previous) => [...previous, { type: 'error', content: latestMessage.error || 'An error occurred with Codex', timestamp: new Date(), errorType: latestMessage.errorType, isRetryable: latestMessage.isRetryable === true }]); + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: latestMessage.error || "An error occurred with Codex", + timestamp: new Date(), + errorType: latestMessage.errorType, + isRetryable: latestMessage.isRetryable === true, + }, + ]); break; - case 'session-aborted': { - const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; - const abortedSessionId = latestMessage.sessionId || currentSessionId; + case "session-aborted": { + const abortedProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const abortedProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const pendingSessionId = readScopedPendingSessionId( + abortedProjectName, + abortedProvider, + ); + const abortedSessionId = routedMessageSessionId || currentSessionId; if (latestMessage.success !== false) { clearLoadingIndicators(); - markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId); - if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) sessionStorage.removeItem('pendingSessionId'); + markSessionsAsCompleted( + abortedSessionId, + currentSessionId, + selectedSession?.id, + pendingSessionId, + ); + if ( + pendingSessionId && + (!abortedSessionId || pendingSessionId === abortedSessionId) + ) + clearScopedPendingSessionId(abortedProjectName, abortedProvider); setPendingPermissionRequests([]); - setChatMessages((previous) => [...previous, { type: 'assistant', content: 'Session interrupted by user.', timestamp: new Date() }]); + setChatMessages((previous) => [ + ...previous, + { + type: "assistant", + content: "Session interrupted by user.", + timestamp: new Date(), + }, + ]); } else { clearLoadingIndicators(); setPendingPermissionRequests([]); - setChatMessages((previous) => [...previous, { type: 'error', content: 'Session has already finished.', timestamp: new Date() }]); + setChatMessages((previous) => [ + ...previous, + { + type: "error", + content: "Session has already finished.", + timestamp: new Date(), + }, + ]); } break; } - case 'session-busy': - console.warn(`[session-busy] Session ${latestMessage.sessionId} is already processing (${latestMessage.provider})`); - setChatMessages((previous) => { - const busyMsg = 'This session is still processing. Please wait for the current response to complete.'; - const last = previous[previous.length - 1]; - if (last?.type === 'error' && last.content === busyMsg) return previous; - return [...previous, { type: 'error', content: busyMsg, timestamp: new Date() }]; - }); - break; + case "session-status": { + const statusSessionId = routedMessageSessionId; + if (!statusSessionId) { + break; + } + + const statusProvider = resolveProvider( + typeof latestMessage.provider === "string" + ? latestMessage.provider + : provider, + ); + const statusProjectName = resolveProjectName( + typeof latestMessage.projectName === "string" + ? latestMessage.projectName + : selectedProject?.name || null, + ); + const isCurrentSession = isMessageInActiveScope( + statusSessionId, + statusProvider, + statusProjectName, + ); + if (latestMessage.isProcessing) { + persistStartTime( + latestMessage.startTime, + statusSessionId, + currentSessionId, + selectedSession?.id, + ); + notifySessionProcessing( + statusSessionId, + statusProvider, + statusProjectName, + ); + + if (!isCurrentSession) { + break; + } - case 'session-status': { - const statusSessionId = latestMessage.sessionId; - const isCurrentSession = statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); - if (isCurrentSession && latestMessage.isProcessing) { - persistStartTime(latestMessage.startTime, statusSessionId, currentSessionId, selectedSession?.id); setIsLoading(true); setCanAbortSession(true); - onSessionProcessing?.(statusSessionId); - onSessionStatusResolved?.(statusSessionId, true); // If we have a startTime from the backend, sync our status if (Number.isFinite(latestMessage.startTime)) { - syncClaudeStatusStartTime(latestMessage.startTime, RESUMING_STATUS_TEXT); + syncClaudeStatusStartTime( + latestMessage.startTime, + RESUMING_STATUS_TEXT, + ); } - } else if (isCurrentSession && latestMessage.isProcessing === false) { + } else if (latestMessage.isProcessing === false) { clearSessionTimerStart(statusSessionId); + notifySessionCompleted( + statusSessionId, + statusProvider, + statusProjectName, + ); + + if (!isCurrentSession) { + break; + } + clearLoadingIndicators(); - onSessionNotProcessing?.(statusSessionId); - onSessionStatusResolved?.(statusSessionId, false); } break; } - case 'claude-permission-request': { + case "claude-permission-request": { const { requestId, toolName, input: toolInput } = latestMessage; if (!requestId || !toolName) break; @@ -1361,43 +2405,58 @@ export function useChatRealtimeHandlers({ requestId, toolName, input: toolInput, - sessionId: latestMessage.sessionId || currentSessionId, + sessionId: routedMessageSessionId || currentSessionId, receivedAt: new Date(), }, ]; }); - + // Ensure UI is in loading/waiting state setIsLoading(true); setCanAbortSession(true); break; } - case 'claude-permission-cancelled': { + case "claude-permission-cancelled": { const { requestId } = latestMessage; if (!requestId) break; - setPendingPermissionRequests((previous) => previous.filter((p) => p.requestId !== requestId)); + setPendingPermissionRequests((previous) => + previous.filter((p) => p.requestId !== requestId), + ); break; } - case 'claude-status': - case 'gemini-status': { + case "claude-status": + case "gemini-status": { const statusData = latestMessage.data; if (!statusData) break; - persistStartTime(statusData.startTime, latestMessage.sessionId, currentSessionId, selectedSession?.id); - const statusInfo = { - text: statusData.message || statusData.status || (typeof statusData === 'string' ? statusData : 'Working...'), - tokens: statusData.tokens || statusData.token_count || 0, - can_interrupt: statusData.can_interrupt !== undefined ? statusData.can_interrupt : true, - startTime: statusData.startTime // Use startTime from message if provided + persistStartTime( + statusData.startTime, + routedMessageSessionId, + currentSessionId, + selectedSession?.id, + ); + const statusInfo = { + text: + statusData.message || + statusData.status || + (typeof statusData === "string" ? statusData : "Working..."), + tokens: statusData.tokens || statusData.token_count || 0, + can_interrupt: + statusData.can_interrupt !== undefined + ? statusData.can_interrupt + : true, + startTime: statusData.startTime, // Use startTime from message if provided }; - + // Use updater function to preserve existing startTime if not provided in message - setClaudeStatus(prev => ({ + setClaudeStatus((prev) => ({ ...statusInfo, - startTime: Number.isFinite(statusInfo.startTime) ? statusInfo.startTime : prev?.startTime + startTime: Number.isFinite(statusInfo.startTime) + ? statusInfo.startTime + : prev?.startTime, })); - + setIsLoading(true); setCanAbortSession(statusInfo.can_interrupt); break; @@ -1407,9 +2466,30 @@ export function useChatRealtimeHandlers({ break; } }, [ - latestMessage, provider, selectedProject, selectedSession, currentSessionId, setCurrentSessionId, - setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setStatusTextOverride, setTokenBudget, - setIsSystemSessionChange, setPendingPermissionRequests, onSessionInactive, onSessionProcessing, - onSessionNotProcessing, onSessionStatusResolved, onReplaceTemporarySession, onNavigateToSession, + latestMessage, + provider, + selectedProject, + selectedSession, + currentSessionId, + setCurrentSessionId, + setChatMessages, + setIsLoading, + setCanAbortSession, + setClaudeStatus, + setStatusTextOverride, + setTokenBudget, + setIsSystemSessionChange, + setPendingPermissionRequests, + onSessionInactive, + onSessionProcessing, + onSessionNotProcessing, + onSessionStatusResolved, + onCodexTurnStarted, + onCodexTurnSettled, + onCodexSessionBusy, + onCodexSessionIdResolved, + onReplaceTemporarySession, + onNavigateToSession, + sendMessage, ]); } diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 0f26e87b..53e06036 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -5,23 +5,48 @@ import { api, authenticatedFetch } from '../../../utils/api'; import { RESUMING_STATUS_TEXT } from '../types/types'; import type { ChatMessage, Provider, TokenBudget } from '../types/types'; import type { Project, ProjectSession } from '../../../types/app'; -import { clearSessionTimerStart, readSessionTimerStart, safeLocalStorage } from '../utils/chatStorage'; +import { + buildChatMessagesStorageKey, + clearScopedProviderSessionId, + persistScopedProviderSessionId, + clearSessionTimerStart, + readSessionTimerStart, + safeLocalStorage, +} from '../utils/chatStorage'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; import { convertCursorSessionMessages, convertSessionMessages, createCachedDiffCalculator, type DiffCalculator, } from '../utils/messageTransforms'; +import { + resolveSessionLoadProvider, + shouldSkipSessionMessageLoad, +} from '../utils/sessionLoadGuards'; +import { buildSessionMessageCacheCandidateKeys } from '../utils/sessionMessageCache'; +import { + buildSessionSnapshotKey, + cloneSessionSnapshot, + createSessionSnapshot, + type SessionSnapshot, +} from '../utils/sessionSnapshotCache'; +import { + buildSessionScopeKey, + isSessionScopeKeyTemporary, + isTemporarySessionId, + parseSessionScopeKey, + scopeKeyMatchesSessionId, +} from '../../../utils/sessionScope'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; /** Grace period for WebSocket status-check response before clearing stale resume state */ -const STATUS_VALIDATION_TIMEOUT_MS = 5000; - +const 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) { @@ -41,6 +66,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; @@ -49,6 +141,7 @@ type PendingViewSession = { interface UseChatSessionStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; + activeProvider?: Provider | null; ws: WebSocket | null; sendMessage: (message: unknown) => void; autoScrollToBottom?: boolean; @@ -63,9 +156,40 @@ interface ScrollRestoreState { top: number; } +const MESSAGE_ID_PREVIEW_LIMIT = 120; + +function toStablePreview(value: unknown, maxLength = MESSAGE_ID_PREVIEW_LIMIT): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value.slice(0, maxLength); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + return JSON.stringify(value).slice(0, maxLength); + } catch { + return String(value).slice(0, maxLength); + } +} + +function buildFallbackMessageFingerprint(message: ChatMessage): string { + const timestampValue = new Date(message.timestamp).getTime(); + const normalizedTimestamp = Number.isFinite(timestampValue) + ? String(timestampValue) + : toStablePreview(message.timestamp, 40); + + return [ + message.type || '', + normalizedTimestamp, + toStablePreview(message.content), + toStablePreview(message.reasoning), + toStablePreview(message.toolName, 80), + toStablePreview(message.toolInput), + message.isToolUse ? 'tool' : 'plain', + ].join('|'); +} + export function useChatSessionState({ selectedProject, selectedSession, + activeProvider, ws, sendMessage, autoScrollToBottom, @@ -77,30 +201,45 @@ export function useChatSessionState({ const persistedInitialStartTime = selectedSession?.id ? readSessionTimerStart(selectedSession.id) : null; const [chatMessages, _setChatMessages] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); - if (saved) { - try { - return JSON.parse(saved) as ChatMessage[]; - } catch { - console.error('Failed to parse saved chat messages, resetting'); - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - return []; - } - } - return []; + if (typeof window !== 'undefined' && selectedProject && selectedSession?.id) { + const 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; let hasChanges = false; + const occurrenceByFingerprint = new Map(); const final = next.map((msg) => { if (!msg.id && !msg.messageId && !msg.toolId && !msg.toolCallId && !msg.blobId && !msg.rowid && !msg.sequence) { + const fingerprint = buildFallbackMessageFingerprint(msg); + const occurrence = (occurrenceByFingerprint.get(fingerprint) || 0) + 1; + occurrenceByFingerprint.set(fingerprint, occurrence); + const cacheKey = `${fingerprint}#${occurrence}`; + const existingId = generatedMessageIdMapRef.current.get(cacheKey); + const nextId = existingId || ((typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID() + : Math.random().toString(36).substring(2, 15)); + if (!existingId) { + generatedMessageIdMapRef.current.set(cacheKey, nextId); + } hasChanges = true; - return { ...msg, messageId: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15) }; + return { ...msg, messageId: nextId }; } return msg; }); @@ -108,9 +247,86 @@ export function useChatSessionState({ }); }, []); + const hasProcessingSession = useCallback( + ( + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, + projectName: string | null | undefined = selectedProject?.name || null, + ) => { + if (!processingSessions || !sessionId || !projectName) { + return false; + } + + const scopeKey = buildSessionScopeKey(projectName, provider || DEFAULT_PROVIDER, sessionId); + if (scopeKey && processingSessions.has(scopeKey)) { + return true; + } + + if (processingSessions.has(sessionId)) { + return true; + } + + for (const trackingKey of processingSessions) { + if (scopeKeyMatchesSessionId(trackingKey, sessionId)) { + const parsed = parseSessionScopeKey(trackingKey); + if (!parsed) { + continue; + } + if (parsed.projectName === projectName) { + const normalizedProvider = normalizeProvider(provider || DEFAULT_PROVIDER); + if (parsed.provider === normalizedProvider) { + return true; + } + } + } + } + + return false; + }, + [processingSessions, selectedProject?.name], + ); + + const resolvePreferredLoadProvider = useCallback( + ( + session: ProjectSession | null, + project: Project | null, + ): Provider => { + if (session?.__provider) { + return resolveSessionLoadProvider(session.__provider); + } + + if (activeProvider) { + return resolveSessionLoadProvider(activeProvider); + } + + if (typeof window !== 'undefined') { + const persistedProvider = window.localStorage.getItem('selected-provider'); + if (persistedProvider) { + return resolveSessionLoadProvider(persistedProvider as Provider); + } + } + + const inferredProvider = resolveSessionProviderForLoad(session, project); + if (inferredProvider) { + return resolveSessionLoadProvider(inferredProvider); + } + + return resolveSessionLoadProvider(DEFAULT_PROVIDER); + }, + [activeProvider], + ); + const [isLoading, setIsLoading] = useState(() => { - if (selectedSession?.id && processingSessions?.has(selectedSession.id)) { - return true; + if (selectedSession?.id && selectedProject?.name) { + const initialProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); + const scopeKey = buildSessionScopeKey( + selectedProject.name, + initialProvider, + selectedSession.id, + ); + if (scopeKey && processingSessions?.has(scopeKey)) { + return true; + } } if (persistedInitialStartTime) { return true; @@ -119,6 +335,7 @@ export function useChatSessionState({ }); const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); const [sessionMessages, setSessionMessages] = useState([]); + const [isSessionMessagesAuthoritative, setIsSessionMessagesAuthoritative] = useState(false); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false); @@ -150,22 +367,86 @@ export function useChatSessionState({ const scrollContainerRef = useRef(null); const isLoadingSessionRef = useRef(false); const isLoadingMoreRef = useRef(false); + const initialLoadCountRef = useRef(0); + const moreLoadCountRef = useRef(0); + const latestSelectionRef = useRef<{ projectName: string | null; sessionId: string | null }>({ + projectName: selectedProject?.name || null, + sessionId: selectedSession?.id || null, + }); + const sessionLoadGenerationRef = useRef(0); + const externalReloadGenerationRef = useRef(0); const allMessagesLoadedRef = useRef(false); const topLoadLockRef = useRef(false); const pendingScrollRestoreRef = useRef(null); const pendingInitialScrollRef = useRef(true); const messagesOffsetRef = useRef(0); + const lastScrollTopRef = useRef(0); const scrollPositionRef = useRef({ height: 0, top: 0 }); const loadAllFinishedTimerRef = useRef | null>(null); const loadAllOverlayTimerRef = useRef | null>(null); + const sessionSnapshotCacheRef = useRef>(new Map()); const createDiff = useMemo(() => createCachedDiffCalculator(), []); + const rememberSessionSnapshot = useCallback( + ( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, + nextSessionMessages: unknown[] | null | undefined, + nextChatMessages: ChatMessage[] | null | undefined, + ) => { + const cacheKey = buildSessionSnapshotKey(projectName, sessionId, provider); + if (!cacheKey) { + return; + } + + const cache = sessionSnapshotCacheRef.current; + if (cache.has(cacheKey)) { + cache.delete(cacheKey); + } + + cache.set(cacheKey, createSessionSnapshot(provider, nextSessionMessages, nextChatMessages)); + + if (cache.size > MAX_SESSION_SNAPSHOT_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey) { + cache.delete(oldestKey); + } + } + }, + [], + ); + + const readSessionSnapshot = useCallback( + ( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider: Provider | string | null | undefined, + ): SessionSnapshot | null => { + const cacheKey = buildSessionSnapshotKey(projectName, sessionId, provider); + if (!cacheKey) { + return null; + } + + const snapshot = sessionSnapshotCacheRef.current.get(cacheKey); + return snapshot ? cloneSessionSnapshot(snapshot) : null; + }, + [], + ); + const pendingStatusValidationSessionIdRef = useRef(pendingStatusValidationSessionId); useEffect(() => { pendingStatusValidationSessionIdRef.current = pendingStatusValidationSessionId; }, [pendingStatusValidationSessionId]); + useEffect(() => { + latestSelectionRef.current = { + projectName: selectedProject?.name || null, + sessionId: selectedSession?.id || null, + }; + }, [selectedProject?.name, selectedSession?.id]); + const markSessionStatusCheckPending = useCallback((sessionId?: string | null) => { if (!sessionId) { return; @@ -183,15 +464,26 @@ export function useChatSessionState({ }, []); const loadSessionMessages = useCallback( - async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => { + async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = DEFAULT_PROVIDER) => { if (!projectName || !sessionId) { return [] as any[]; } + if (shouldSkipSessionMessageLoad(sessionId)) { + if (!loadMore) { + messagesOffsetRef.current = 0; + setHasMoreMessages(false); + setTotalMessages(0); + } + return [] as any[]; + } + const isInitialLoad = !loadMore; if (isInitialLoad) { + initialLoadCountRef.current += 1; setIsLoadingSessionMessages(true); } else { + moreLoadCountRef.current += 1; setIsLoadingMoreMessages(true); } @@ -209,7 +501,6 @@ export function useChatSessionState({ } const data = await response.json(); - console.log('[DEBUG] Received session messages data:', data); if (isInitialLoad && data.tokenUsage) { setTokenBudget(data.tokenUsage); } @@ -232,9 +523,11 @@ export function useChatSessionState({ return []; } finally { if (isInitialLoad) { - setIsLoadingSessionMessages(false); + initialLoadCountRef.current = Math.max(0, initialLoadCountRef.current - 1); + setIsLoadingSessionMessages(initialLoadCountRef.current > 0); } else { - setIsLoadingMoreMessages(false); + moreLoadCountRef.current = Math.max(0, moreLoadCountRef.current - 1); + setIsLoadingMoreMessages(moreLoadCountRef.current > 0); } } }, @@ -246,6 +539,7 @@ export function useChatSessionState({ return [] as ChatMessage[]; } + initialLoadCountRef.current += 1; setIsLoadingSessionMessages(true); try { const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`; @@ -261,7 +555,8 @@ export function useChatSessionState({ console.error('Error loading Cursor session messages:', error); return []; } finally { - setIsLoadingSessionMessages(false); + initialLoadCountRef.current = Math.max(0, initialLoadCountRef.current - 1); + setIsLoadingSessionMessages(initialLoadCountRef.current > 0); } }, []); @@ -305,7 +600,7 @@ export function useChatSessionState({ return false; } - const sessionProvider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider | string; + const sessionProvider = normalizeProvider(selectedSession.__provider || DEFAULT_PROVIDER); if (sessionProvider === 'cursor') { return false; } @@ -330,7 +625,17 @@ export function useChatSessionState({ height: previousScrollHeight, top: previousScrollTop, }; - setSessionMessages((previous) => [...moreMessages, ...previous]); + setSessionMessages((previous) => { + const nextMessages = [...moreMessages, ...previous]; + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + sessionProvider, + nextMessages, + [], + ); + return nextMessages; + }); // Keep the rendered window in sync with top-pagination so newly loaded history becomes visible. setVisibleMessageCount((previousCount) => previousCount + moreMessages.length); return true; @@ -338,7 +643,7 @@ export function useChatSessionState({ isLoadingMoreRef.current = false; } }, - [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession], + [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, rememberSessionSnapshot, selectedProject, selectedSession], ); const handleScroll = useCallback(async () => { @@ -347,18 +652,27 @@ export function useChatSessionState({ return; } + const currentScrollTop = container.scrollTop; + const wasScrollingUp = currentScrollTop <= lastScrollTopRef.current; + lastScrollTopRef.current = currentScrollTop; + const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); if (!allMessagesLoadedRef.current) { - const scrolledNearTop = container.scrollTop < 100; + if (!wasScrollingUp) { + topLoadLockRef.current = false; + return; + } + + const scrolledNearTop = currentScrollTop < 100; if (!scrolledNearTop) { topLoadLockRef.current = false; return; } if (topLoadLockRef.current) { - if (container.scrollTop > 20) { + if (currentScrollTop > 20) { topLoadLockRef.current = false; } return; @@ -382,14 +696,21 @@ export function useChatSessionState({ const scrollDiff = newScrollHeight - height; container.scrollTop = top + Math.max(scrollDiff, 0); pendingScrollRestoreRef.current = null; - }, [chatMessages.length]); + }, [chatMessages.length, sessionMessages.length]); useEffect(() => { pendingInitialScrollRef.current = true; topLoadLockRef.current = false; pendingScrollRestoreRef.current = null; + lastScrollTopRef.current = 0; + initialLoadCountRef.current = 0; + moreLoadCountRef.current = 0; + generatedMessageIdMapRef.current.clear(); setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); setIsUserScrolledUp(false); + setIsSessionMessagesAuthoritative(false); + setIsLoadingSessionMessages(false); + setIsLoadingMoreMessages(false); }, [selectedProject?.name, selectedSession?.id]); useEffect(() => { @@ -409,18 +730,55 @@ export function useChatSessionState({ }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); useEffect(() => { + let cancelled = false; + const requestGeneration = sessionLoadGenerationRef.current + 1; + sessionLoadGenerationRef.current = requestGeneration; + const requestSelection = { + projectName: selectedProject?.name || null, + sessionId: selectedSession?.id || null, + }; + const isStaleRequest = () => + cancelled + || sessionLoadGenerationRef.current !== requestGeneration + || latestSelectionRef.current.projectName !== requestSelection.projectName + || latestSelectionRef.current.sessionId !== requestSelection.sessionId; + const loadMessages = async () => { if (selectedSession && selectedProject) { - const currentProvider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider | string; + const currentProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); isLoadingSessionRef.current = true; + const cachedSnapshot = + !isSystemSessionChange + ? readSessionSnapshot(selectedProject.name, selectedSession.id, currentProvider) + : null; + const cachedStoredMessages = + !isSystemSessionChange + ? readStoredChatMessages(selectedProject.name, selectedSession.id, currentProvider) + : []; const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; if (sessionChanged) { if (!isSystemSessionChange) { resetStreamingState(); pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); + if (cachedSnapshot) { + if (currentProvider === 'cursor') { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages(cachedSnapshot.chatMessages); + } else { + setSessionMessages(cachedSnapshot.sessionMessages); + setIsSessionMessagesAuthoritative(true); + } + } else if (cachedStoredMessages.length > 0) { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages(cachedStoredMessages); + } else { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages([]); + } setClaudeStatus(null); setCanAbortSession(false); } @@ -440,13 +798,17 @@ export function useChatSessionState({ // Only set isLoading to false if it's NOT in the processingSessions set const isProcessing = - processingSessions?.has(selectedSession.id) || + hasProcessingSession(selectedSession.id, currentProvider, selectedProject.name) || pendingStatusValidationSessionIdRef.current === selectedSession.id; if (!isProcessing) { setIsLoading(false); } } + if (isStaleRequest()) { + return; + } + // Always check status if we have a websocket and a session, // especially on initial load or reconnect. if (ws && selectedSession?.id) { @@ -460,13 +822,31 @@ export function useChatSessionState({ if (currentProvider === 'cursor') { setCurrentSessionId(selectedSession.id); - sessionStorage.setItem('cursorSessionId', selectedSession.id); + persistScopedProviderSessionId(selectedProject.name, 'cursor', selectedSession.id); if (!isSystemSessionChange) { const projectPath = selectedProject.fullPath || selectedProject.path || ''; const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); + if (isStaleRequest()) { + return; + } + const shouldKeepCachedCursorMessages = + converted.length === 0 + && cachedStoredMessages.length > 0 + && hasSessionHistoryHint(selectedSession); + const nextCursorMessages = shouldKeepCachedCursorMessages + ? cachedStoredMessages + : converted; setSessionMessages([]); - setChatMessages(converted); + setIsSessionMessagesAuthoritative(false); + setChatMessages(nextCursorMessages); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + currentProvider, + [], + nextCursorMessages, + ); } else { setIsSystemSessionChange(false); } @@ -480,44 +860,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, @@ -529,15 +988,51 @@ export function useChatSessionState({ return; } + let cancelled = false; + const requestGeneration = externalReloadGenerationRef.current + 1; + externalReloadGenerationRef.current = requestGeneration; + const reloadSelection = { + projectName: selectedProject.name, + sessionId: selectedSession.id, + }; + const isStaleReload = () => + cancelled + || externalReloadGenerationRef.current !== requestGeneration + || latestSelectionRef.current.projectName !== reloadSelection.projectName + || latestSelectionRef.current.sessionId !== reloadSelection.sessionId; + const reloadExternalMessages = async () => { try { - const provider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider; + const provider = resolvePreferredLoadProvider(selectedSession, selectedProject); + const cachedStoredMessages = readStoredChatMessages( + selectedProject.name, + selectedSession.id, + provider, + ); if (provider === 'cursor') { const projectPath = selectedProject.fullPath || selectedProject.path || ''; const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); + if (isStaleReload()) { + return; + } + const shouldKeepCachedCursorMessages = + converted.length === 0 + && cachedStoredMessages.length > 0 + && hasSessionHistoryHint(selectedSession); + const nextCursorMessages = shouldKeepCachedCursorMessages + ? cachedStoredMessages + : converted; setSessionMessages([]); - setChatMessages(converted); + setIsSessionMessagesAuthoritative(false); + setChatMessages(nextCursorMessages); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + provider, + [], + nextCursorMessages, + ); return; } @@ -547,7 +1042,36 @@ export function useChatSessionState({ false, provider, ); - setSessionMessages(messages); + if (isStaleReload()) { + return; + } + const shouldKeepCachedHistory = + messages.length === 0 + && cachedStoredMessages.length > 0 + && hasSessionHistoryHint(selectedSession); + + if (shouldKeepCachedHistory) { + setSessionMessages([]); + setIsSessionMessagesAuthoritative(false); + setChatMessages(cachedStoredMessages); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + provider, + [], + cachedStoredMessages, + ); + } else { + setSessionMessages(messages); + setIsSessionMessagesAuthoritative(true); + rememberSessionSnapshot( + selectedProject.name, + selectedSession.id, + provider, + messages, + [], + ); + } const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom(); if (shouldAutoScroll) { @@ -559,12 +1083,17 @@ export function useChatSessionState({ }; reloadExternalMessages(); + return () => { + cancelled = true; + }; }, [ autoScrollToBottom, externalMessageUpdate, isNearBottom, loadCursorSessionMessages, loadSessionMessages, + rememberSessionSnapshot, + resolvePreferredLoadProvider, scrollToBottom, selectedProject, selectedSession, @@ -577,24 +1106,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; @@ -616,7 +1179,7 @@ export function useChatSessionState({ }; fetchInitialTokenUsage(); - }, [selectedProject, selectedSession]); + }, [resolvePreferredLoadProvider, selectedProject, selectedSession]); const visibleMessages = useMemo(() => { if (chatMessages.length <= visibleMessageCount) { @@ -694,7 +1257,12 @@ export function useChatSessionState({ }); } - const isTrackedProcessing = Boolean(processingSessions?.has(activeViewSessionId)); + const activeProvider = resolvePreferredLoadProvider(selectedSession, selectedProject); + const isTrackedProcessing = hasProcessingSession( + activeViewSessionId, + activeProvider, + selectedProject?.name || null, + ); const isAwaitingStatusValidation = pendingStatusValidationSessionId === activeViewSessionId && Boolean(persistedStartTime); const shouldBeProcessing = isTrackedProcessing || isAwaitingStatusValidation; @@ -703,7 +1271,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; @@ -711,13 +1289,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; } @@ -736,7 +1334,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); @@ -763,7 +1371,7 @@ export function useChatSessionState({ const loadAllMessages = useCallback(async () => { if (!selectedSession || !selectedProject) return; if (isLoadingAllMessages) return; - const sessionProvider = resolveSessionProviderForLoad(selectedSession, selectedProject) as Provider | string; + const sessionProvider = normalizeProvider(selectedSession.__provider || DEFAULT_PROVIDER); if (sessionProvider === 'cursor') { setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); @@ -814,6 +1422,13 @@ export function useChatSessionState({ setHasMoreMessages(false); setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0); messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0; + rememberSessionSnapshot( + selectedProject.name, + requestSessionId, + sessionProvider, + Array.isArray(allMessages) ? allMessages : [], + [], + ); setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); @@ -836,7 +1451,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 fa62930c..45713404 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -151,12 +151,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, @@ -170,13 +191,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__/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/sessionMessageCache.ts b/src/components/chat/utils/sessionMessageCache.ts index a0e8c67a..99b61001 100644 --- a/src/components/chat/utils/sessionMessageCache.ts +++ b/src/components/chat/utils/sessionMessageCache.ts @@ -1,15 +1,8 @@ import type { Provider } from '../types/types'; +import { buildChatMessagesStorageKey } from './chatStorage'; import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; -const CHAT_MESSAGES_PREFIX = 'chat_messages_'; - -function buildChatMessagesStorageKey( - projectName: string, - sessionId: string, - provider: string, -): string { - return `${CHAT_MESSAGES_PREFIX}${projectName}_${provider}_${sessionId}`; -} +const LEGACY_CHAT_MESSAGES_PREFIX = 'chat_messages_'; type SessionMessageCacheLookupOptions = { allowLegacyFallback?: boolean; @@ -35,7 +28,7 @@ export function buildSessionMessageCacheCandidateKeys( new Set([ providerScopedKey, buildChatMessagesStorageKey(projectName, sessionId, DEFAULT_PROVIDER), - `${CHAT_MESSAGES_PREFIX}${projectName}_${sessionId}`, + `${LEGACY_CHAT_MESSAGES_PREFIX}${projectName}_${sessionId}`, ].filter(Boolean)), ); } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index d707fa10..1d7300b3 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -228,6 +228,7 @@ function ChatInterface({ } = useChatSessionState({ selectedProject, selectedSession, + activeProvider: provider, ws, sendMessage, autoScrollToBottom, @@ -357,6 +358,7 @@ function ChatInterface({ onSessionStatusResolved: resolveSessionStatusCheck, onReplaceTemporarySession, onNavigateToSession, + sendMessage, }); const handleRetry = useCallback(() => { @@ -979,7 +981,7 @@ function ChatInterface({ onSidebarTabChange={setSidebarTab} isCollapsed={isSidebarCollapsed} onCollapsedChange={setIsSidebarCollapsed} - onStartWorkspaceQa={onStartWorkspaceQa} + onStartWorkspaceQa={onStartWorkspaceQa ?? undefined} onStartTask={handleStartTaskInChat} /> diff --git a/src/components/main-content/view/subcomponents/ShellWorkspace.tsx b/src/components/main-content/view/subcomponents/ShellWorkspace.tsx index 3f3fba39..691a46f3 100644 --- a/src/components/main-content/view/subcomponents/ShellWorkspace.tsx +++ b/src/components/main-content/view/subcomponents/ShellWorkspace.tsx @@ -289,7 +289,7 @@ export default function ShellWorkspace({ project, session = null }: ShellWorkspa > {isSessionTab && session ? ( getAllSessions(project, additionalSessions), - [additionalSessions], + (project: Project) => { + const sessions = getAllSessions(project, additionalSessions); + return prependSelectedSessionIfMissing( + sessions, + project.name, + selectedSession, + selectedProject?.name || null, + ); + }, + [additionalSessions, selectedProject?.name, selectedSession], ); const projectsWithSessionMeta = useMemo( diff --git a/src/components/sidebar/utils/__tests__/sessionListBehavior.test.ts b/src/components/sidebar/utils/__tests__/sessionListBehavior.test.ts new file mode 100644 index 00000000..5dce1329 --- /dev/null +++ b/src/components/sidebar/utils/__tests__/sessionListBehavior.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import type { ProjectSession } from '../../../../types/app'; +import { + getAllSessions, + prependSelectedSessionIfMissing, +} from '../utils'; + +describe('sidebar session list behavior', () => { + it('preserves provider from additional sessions instead of forcing claude', () => { + const sessions = getAllSessions( + { + name: 'proj-a', + displayName: 'proj-a', + fullPath: 'C:\\proj-a', + sessions: [], + codexSessions: [], + }, + { + 'proj-a': [ + { + id: 'sess-1', + summary: 'Codex temp', + __provider: 'codex', + __projectName: 'proj-a', + createdAt: '2026-04-12T10:00:00.000Z', + lastActivity: '2026-04-12T10:00:00.000Z', + }, + ], + }, + ); + + expect(sessions).toHaveLength(1); + expect(sessions[0].__provider).toBe('codex'); + }); + + it('injects selected session immediately when it is missing from project list', () => { + const baseSessions = getAllSessions( + { + name: 'proj-a', + displayName: 'proj-a', + fullPath: 'C:\\proj-a', + sessions: [], + }, + {}, + ); + + const selectedSession: ProjectSession = { + id: 'new-session-123', + summary: 'New Session', + __provider: 'codex', + __projectName: 'proj-a', + createdAt: '2026-04-12T10:00:00.000Z', + lastActivity: '2026-04-12T10:00:00.000Z', + }; + + const merged = prependSelectedSessionIfMissing( + baseSessions, + 'proj-a', + selectedSession, + 'proj-a', + ); + + expect(merged).toHaveLength(1); + expect(merged[0].id).toBe('new-session-123'); + expect(merged[0].__provider).toBe('codex'); + }); + + it('does not duplicate when same project/provider/session already exists', () => { + const baseSessions = getAllSessions( + { + name: 'proj-a', + displayName: 'proj-a', + fullPath: 'C:\\proj-a', + codexSessions: [ + { + id: 'sess-1', + summary: 'Existing', + createdAt: '2026-04-12T10:00:00.000Z', + lastActivity: '2026-04-12T10:00:00.000Z', + }, + ], + }, + {}, + ); + + const selectedSession: ProjectSession = { + id: 'sess-1', + summary: 'Existing', + __provider: 'codex', + __projectName: 'proj-a', + }; + + const merged = prependSelectedSessionIfMissing( + baseSessions, + 'proj-a', + selectedSession, + 'proj-a', + ); + + expect(merged).toHaveLength(1); + }); + + it('treats same session id under different providers as different identities', () => { + const baseSessions = getAllSessions( + { + name: 'proj-a', + displayName: 'proj-a', + fullPath: 'C:\\proj-a', + codexSessions: [ + { + id: 'sess-1', + summary: 'Codex existing', + createdAt: '2026-04-12T10:00:00.000Z', + lastActivity: '2026-04-12T10:00:00.000Z', + }, + ], + }, + {}, + ); + + const selectedSession: ProjectSession = { + id: 'sess-1', + summary: 'Gemini selected', + __provider: 'gemini', + __projectName: 'proj-a', + createdAt: '2026-04-12T10:01:00.000Z', + lastActivity: '2026-04-12T10:01:00.000Z', + }; + + const merged = prependSelectedSessionIfMissing( + baseSessions, + 'proj-a', + selectedSession, + 'proj-a', + ); + + expect(merged).toHaveLength(2); + expect( + merged.some((session) => session.id === 'sess-1' && session.__provider === 'gemini'), + ).toBe(true); + expect( + merged.some((session) => session.id === 'sess-1' && session.__provider === 'codex'), + ).toBe(true); + }); +}); diff --git a/src/components/sidebar/utils/utils.ts b/src/components/sidebar/utils/utils.ts index 4fa0eac0..4356043a 100644 --- a/src/components/sidebar/utils/utils.ts +++ b/src/components/sidebar/utils/utils.ts @@ -1,6 +1,7 @@ import type { TFunction } from 'i18next'; -import type { Project } from '../../../types/app'; +import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import { stripInternalContextPrefix } from '../../../utils/sessionFormatting'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; import type { AdditionalSessionsByProject, ProjectSortOrder, @@ -123,7 +124,11 @@ export const getAllSessions = ( const claudeSessions = [ ...(project.sessions || []), ...(additionalSessions[project.name] || []), - ].map((session) => ({ ...session, __provider: 'claude' as const, __projectName: project.name })); + ].map((session) => ({ + ...session, + __provider: normalizeProvider((session.__provider || 'claude') as SessionProvider), + __projectName: session.__projectName || project.name, + })); const cursorSessions = (project.cursorSessions || []).map((session) => ({ ...session, @@ -166,6 +171,60 @@ export const getAllSessions = ( ); }; +const buildSessionIdentityKey = ( + session: Pick, + projectName: string, +): string => { + const scopedProjectName = session.__projectName || projectName; + const scopedProvider = normalizeProvider((session.__provider || DEFAULT_PROVIDER) as SessionProvider); + return `${scopedProjectName}::${scopedProvider}::${session.id}`; +}; + +export const prependSelectedSessionIfMissing = ( + sessions: SessionWithProvider[], + projectName: string, + selectedSession: ProjectSession | null, + selectedProjectName: string | null, +): SessionWithProvider[] => { + if (!selectedSession?.id) { + return sessions; + } + + const scopedProjectName = selectedSession.__projectName || selectedProjectName; + if (!scopedProjectName || scopedProjectName !== projectName) { + return sessions; + } + + const selectedIdentityKey = buildSessionIdentityKey(selectedSession, projectName); + const alreadyPresent = sessions.some( + (session) => buildSessionIdentityKey(session, projectName) === selectedIdentityKey, + ); + if (alreadyPresent) { + return sessions; + } + + const fallbackTimestamp = new Date().toISOString(); + const optimisticSession: SessionWithProvider = { + ...selectedSession, + __provider: normalizeProvider((selectedSession.__provider || DEFAULT_PROVIDER) as SessionProvider), + __projectName: projectName, + createdAt: + selectedSession.createdAt || + selectedSession.created_at || + fallbackTimestamp, + lastActivity: + selectedSession.lastActivity || + selectedSession.updated_at || + selectedSession.createdAt || + selectedSession.created_at || + fallbackTimestamp, + }; + + return [optimisticSession, ...sessions].sort( + (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), + ); +}; + export const getProjectLastActivity = ( project: Project, additionalSessions: AdditionalSessionsByProject, diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx index 3494d242..106cd1f0 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx @@ -93,7 +93,7 @@ export default function SidebarProjectSessions({ ) : ( sessions.map((session) => ( { onProjectSelect(project); @@ -214,7 +226,7 @@ export default function SidebarSessionItem({ {!sessionView.isCursorSession && (
- {editingSession === session.id && !sessionView.isCodexSession ? ( + {editingSession === sessionEditKey && !sessionView.isCodexSession ? ( <> { event.stopPropagation(); - onStartEditingSession(session.id, session.summary || t('projects.newSession')); + onStartEditingSession(sessionEditKey, session.summary || t('projects.newSession')); }} title={t('tooltips.editSessionName')} >