diff --git a/eslint.config.js b/eslint.config.js index 6419a9fd..57a71453 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -157,7 +157,7 @@ export default tseslint.config( }, { type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly - pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly + pattern: ["server/shared/utils.{js,ts}", "server/shared/claude-cli-path.ts"], // classify the shared utils file so modules can depend on it explicitly mode: "file", }, { diff --git a/server/claude-sdk.js b/server/claude-sdk.js index db3a205c..0f1cac97 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -18,6 +18,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { CLAUDE_MODELS } from '../shared/modelConstants.js'; +import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; import { createNotificationEvent, notifyRunFailed, @@ -153,11 +154,9 @@ function mapCliOptionsToSDK(options = {}) { // Since SDK 0.2.113, options.env replaces process.env instead of overlaying it. sdkOptions.env = { ...process.env }; - // Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH. - // The SDK 0.2.113+ looks for a bundled native binary optional dep by default; - // this fallback ensures users who installed via the official installer still work - // even when npm prune --production has removed those optional deps. - sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude'; + // Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn, + // which does not reliably follow npm's shell wrappers like cross-spawn does. + sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH); // Map working directory if (cwd) { diff --git a/server/modules/providers/list/claude/claude-auth.provider.ts b/server/modules/providers/list/claude/claude-auth.provider.ts index 1194ae1d..c94fe1b4 100644 --- a/server/modules/providers/list/claude/claude-auth.provider.ts +++ b/server/modules/providers/list/claude/claude-auth.provider.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import spawn from 'cross-spawn'; +import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js'; import type { IProviderAuth } from '@/shared/interfaces.js'; import type { ProviderAuthStatus } from '@/shared/types.js'; import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; @@ -20,13 +21,13 @@ export class ClaudeProviderAuth implements IProviderAuth { * Checks whether the Claude Code CLI is available on this host. */ private checkInstalled(): boolean { - const cliPath = process.env.CLAUDE_CLI_PATH || 'claude'; - try { - spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); - return true; - } catch { - return false; - } + const cliPath = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH); + try { + spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } } /** diff --git a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts index 7d089a2d..66f055fd 100644 --- a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +++ b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts @@ -1,5 +1,6 @@ import os from 'node:os'; import path from 'node:path'; +import { readFile } from 'node:fs/promises'; import { sessionsDb } from '@/modules/database/index.js'; import { @@ -91,7 +92,7 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { filePath: string, nameMap: Map ): Promise { - return extractFirstValidJsonlData(filePath, (rawData) => { + const parsed = await extractFirstValidJsonlData(filePath, (rawData) => { const data = rawData as Record; const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined; @@ -103,8 +104,68 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { return { sessionId, projectPath, - sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Claude Session'), }; }); + + if (!parsed) { + return null; + } + + const existingSession = sessionsDb.getSessionById(parsed.sessionId); + const existingSessionName = existingSession?.custom_name; + if (existingSessionName && existingSessionName !== 'Untitled Claude Session') { + return { + ...parsed, + sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'), + }; + } + + let sessionName = nameMap.get(parsed.sessionId); + if (!sessionName) { + sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId); + } + + return { + ...parsed, + sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'), + }; + } + + private async extractSessionAiTitleFromEnd( + filePath: string, + sessionId: string + ): Promise { + try { + const content = await readFile(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + const data = parsed as Record; + const eventType = typeof data.type === 'string' ? data.type : undefined; + const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; + const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined; + const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined; + + if ((eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim())) { + return aiTitle || lastPrompt; + } + } + } catch { + // Ignore missing/unreadable files so sync can continue. + } + + return undefined; } } diff --git a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts index bd1edc0c..0e8025ef 100644 --- a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +++ b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts @@ -1,5 +1,6 @@ import os from 'node:os'; import path from 'node:path'; +import { readFile } from 'node:fs/promises'; import { sessionsDb } from '@/modules/database/index.js'; import { @@ -99,7 +100,7 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { filePath: string, nameMap: Map ): Promise { - return extractFirstValidJsonlData(filePath, (rawData) => { + const parsed = await extractFirstValidJsonlData(filePath, (rawData) => { const data = rawData as Record; const payload = data.payload as Record | undefined; const sessionId = typeof payload?.id === 'string' ? payload.id : undefined; @@ -112,8 +113,67 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { return { sessionId, projectPath, - sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Codex Session'), }; }); + + if (!parsed) { + return null; + } + + const existingSession = sessionsDb.getSessionById(parsed.sessionId); + const existingSessionName = existingSession?.custom_name; + if (existingSessionName && existingSessionName !== 'Untitled Codex Session') { + return { + ...parsed, + sessionName: normalizeSessionName(existingSessionName, 'Untitled Codex Session'), + }; + } + + let sessionName = nameMap.get(parsed.sessionId); + if (!sessionName) { + sessionName = await this.extractLastAgentMessageFromEnd(filePath); + } + + return { + ...parsed, + sessionName: normalizeSessionName(sessionName, 'Untitled Codex Session'), + }; + } + + private async extractLastAgentMessageFromEnd(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + const data = parsed as Record; + const eventType = typeof data.type === 'string' ? data.type : undefined; + const payload = data.payload as Record | undefined; + const payloadType = typeof payload?.type === 'string' ? payload.type : undefined; + const lastAgentMessage = typeof payload?.last_agent_message === 'string' + ? payload.last_agent_message + : undefined; + + if (eventType === 'event_msg' && payloadType === 'task_complete' && lastAgentMessage?.trim()) { + return lastAgentMessage; + } + } + } catch { + // Ignore missing/unreadable files so sync can continue. + } + + return undefined; } } diff --git a/server/shared/claude-cli-path.test.ts b/server/shared/claude-cli-path.test.ts new file mode 100644 index 00000000..87cde218 --- /dev/null +++ b/server/shared/claude-cli-path.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + resolveClaudeCodeExecutablePath, + type ResolveClaudeCodeExecutablePathDependencies, +} from '@/shared/claude-cli-path.js'; + +test('resolveClaudeCodeExecutablePath resolves the npm Claude wrapper to its native exe on Windows', () => { + const wrapperDir = 'C:\\nvm4w\\nodejs'; + const nativePath = `${wrapperDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + const execFileSync = + (() => `${wrapperDir}\\claude\r\n${wrapperDir}\\claude.cmd\r\n`) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync']; + const readFileSync = (() => '') as unknown as ResolveClaudeCodeExecutablePathDependencies['readFileSync']; + + const resolved = resolveClaudeCodeExecutablePath('claude', { + platform: 'win32', + execFileSync, + existsSync: (candidate) => candidate === nativePath, + readFileSync, + }); + + assert.equal(resolved, nativePath); +}); + +test('resolveClaudeCodeExecutablePath keeps an explicit JavaScript launcher path unchanged', () => { + const scriptPath = 'C:\\tools\\claude.js'; + + const resolved = resolveClaudeCodeExecutablePath(scriptPath, { + platform: 'win32', + }); + + assert.equal(resolved, scriptPath); +}); + +test('resolveClaudeCodeExecutablePath falls back to the configured command when PATH lookup fails', () => { + const execFileSync = (() => { + throw new Error('not found'); + }) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync']; + + const resolved = resolveClaudeCodeExecutablePath('claude', { + platform: 'win32', + execFileSync, + }); + + assert.equal(resolved, 'claude'); +}); diff --git a/server/shared/claude-cli-path.ts b/server/shared/claude-cli-path.ts new file mode 100644 index 00000000..ae8565d5 --- /dev/null +++ b/server/shared/claude-cli-path.ts @@ -0,0 +1,139 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_CLAUDE_COMMAND = 'claude'; +const CLAUDE_SCRIPT_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']); +const CLAUDE_WRAPPER_SEGMENTS = ['node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'] as const; + +export type ResolveClaudeCodeExecutablePathDependencies = { + execFileSync?: typeof execFileSync; + existsSync?: typeof fs.existsSync; + platform?: NodeJS.Platform; + readFileSync?: typeof fs.readFileSync; +}; + +function getPathApi(platform: NodeJS.Platform) { + return platform === 'win32' ? path.win32 : path; +} + +function stripWrappingQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function isPathLike(value: string): boolean { + return value.includes('/') || value.includes('\\'); +} + +function resolveClaudeWrapperBinary( + wrapperPath: string, + deps: Required, +): string | null { + const pathApi = getPathApi(deps.platform); + const directCandidate = pathApi.resolve(pathApi.dirname(wrapperPath), ...CLAUDE_WRAPPER_SEGMENTS); + + if (deps.existsSync(directCandidate)) { + return directCandidate; + } + + let content: string; + try { + content = deps.readFileSync(wrapperPath, 'utf8'); + } catch { + return null; + } + + const matches = content.matchAll(/["']([^"'\\r\\n]*claude\.exe)["']/gi); + for (const match of matches) { + const rawTarget = match[1] + .replace(/^\$basedir[\\/]/i, '') + .replace(/^%dp0%[\\/]/i, '') + .replace(/^%~dp0[\\/]/i, ''); + const normalizedTarget = rawTarget.replace(/[\\/]/g, pathApi.sep); + const candidate = pathApi.isAbsolute(normalizedTarget) + ? normalizedTarget + : pathApi.resolve(pathApi.dirname(wrapperPath), normalizedTarget); + + if (deps.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function resolveWindowsClaudeExecutablePath( + configuredPath: string, + deps: Required, +): string { + const pathApi = getPathApi(deps.platform); + const extension = pathApi.extname(configuredPath).toLowerCase(); + const explicitPath = isPathLike(configuredPath) || pathApi.isAbsolute(configuredPath); + + if (CLAUDE_SCRIPT_EXTENSIONS.has(extension)) { + return configuredPath; + } + + if (explicitPath && extension === '.exe') { + return configuredPath; + } + + if (explicitPath) { + return resolveClaudeWrapperBinary(configuredPath, deps) ?? configuredPath; + } + + try { + const stdout = deps.execFileSync('where.exe', [configuredPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + const candidates = stdout + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); + + for (const candidate of candidates) { + if (pathApi.extname(candidate).toLowerCase() === '.exe') { + return candidate; + } + } + + for (const candidate of candidates) { + const resolved = resolveClaudeWrapperBinary(candidate, deps); + if (resolved) { + return resolved; + } + } + } catch { + return configuredPath; + } + + return configuredPath; +} + +export function resolveClaudeCodeExecutablePath( + configuredPath: string | undefined = process.env.CLAUDE_CLI_PATH, + dependencies: ResolveClaudeCodeExecutablePathDependencies = {}, +): string { + const deps: Required = { + execFileSync: dependencies.execFileSync ?? execFileSync, + existsSync: dependencies.existsSync ?? fs.existsSync, + platform: dependencies.platform ?? process.platform, + readFileSync: dependencies.readFileSync ?? fs.readFileSync, + }; + + const normalizedPath = stripWrappingQuotes(configuredPath || DEFAULT_CLAUDE_COMMAND); + if (deps.platform !== 'win32') { + return normalizedPath; + } + + return resolveWindowsClaudeExecutablePath(normalizedPath, deps); +} diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 432447e4..4dd6979a 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -44,6 +44,7 @@ function AppContentInner() { sidebarOpen, isLoadingProjects, externalMessageUpdate, + newSessionTrigger, setActiveTab, setSidebarOpen, setIsInputFocused, @@ -191,9 +192,12 @@ function AppContentInner() { onSessionNotProcessing={markSessionAsNotProcessing} processingSessions={processingSessions} onReplaceTemporarySession={replaceTemporarySession} - onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)} + onNavigateToSession={(targetSessionId: string, options) => + navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) }) + } onShowSettings={() => setShowSettings(true)} externalMessageUpdate={externalMessageUpdate} + newSessionTrigger={newSessionTrigger} /> diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 855ee788..342ea117 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,7 +1,8 @@ import { useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; + import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; -import type { PendingPermissionRequest } from '../types/types'; +import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; @@ -67,7 +68,7 @@ interface UseChatRealtimeHandlersArgs { onSessionProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void; onReplaceTemporarySession?: (sessionId?: string | null) => void; - onNavigateToSession?: (sessionId: string) => void; + onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void; onWebSocketReconnect?: () => void; sessionStore: SessionStore; } @@ -273,13 +274,53 @@ export function useChatRealtimeHandlers({ break; } - // Clear pending session + const actualSessionId = + typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0 + ? msg.actualSessionId + : null; const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - if (pendingSessionId && !currentSessionId && msg.exitCode === 0) { - const actualId = msg.actualSessionId || pendingSessionId; - setCurrentSessionId(actualId); - if (msg.actualSessionId) { - onNavigateToSession?.(actualId); + const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0; + const isVisibleSession = + Boolean( + sid + && ( + sid === activeViewSessionId + || sid === pendingSessionId + || pendingViewSessionRef.current?.sessionId === sid + ), + ); + + if (actualSessionId && sid && actualSessionId !== sid) { + sessionStore.replaceSessionId(sid, actualSessionId); + + if (isVisibleSession) { + setCurrentSessionId(actualSessionId); + + if (pendingViewSessionRef.current) { + const pendingSession = pendingViewSessionRef.current.sessionId; + if (!pendingSession || pendingSession === sid) { + pendingViewSessionRef.current.sessionId = actualSessionId; + } + } + } + + if (completedSuccessfully && pendingSessionId === sid) { + sessionStorage.removeItem('pendingSessionId'); + } + + if (isVisibleSession) { + onNavigateToSession?.(actualSessionId, { replace: true }); + setTimeout(() => { void paletteOps.refreshProjects(); }, 500); + } + break; + } + + // Clear pending session + if (pendingSessionId && !currentSessionId && completedSuccessfully) { + const resolvedSessionId = actualSessionId || pendingSessionId; + setCurrentSessionId(resolvedSessionId); + if (actualSessionId) { + onNavigateToSession?.(resolvedSessionId, { replace: true }); } sessionStorage.removeItem('pendingSessionId'); setTimeout(() => { void paletteOps.refreshProjects(); }, 500); diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 3ad66f82..6bff4a88 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -1,11 +1,13 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; + import { authenticatedFetch } from '../../../utils/api'; -import type { ChatMessage, Provider } from '../types/types'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; -import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; -import { normalizedToChatMessages } from './useChatMessages'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; +import type { ChatMessage, Provider } from '../types/types'; +import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; + +import { normalizedToChatMessages } from './useChatMessages'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; @@ -22,6 +24,7 @@ interface UseChatSessionStateArgs { sendMessage: (message: unknown) => void; autoScrollToBottom?: boolean; externalMessageUpdate?: number; + newSessionTrigger?: number; processingSessions?: Set; resetStreamingState: () => void; pendingViewSessionRef: MutableRefObject; @@ -95,6 +98,7 @@ export function useChatSessionState({ sendMessage, autoScrollToBottom, externalMessageUpdate, + newSessionTrigger, processingSessions, resetStreamingState, pendingViewSessionRef, @@ -131,15 +135,85 @@ export function useChatSessionState({ const loadAllFinishedTimerRef = useRef | null>(null); const loadAllOverlayTimerRef = useRef | null>(null); const lastLoadedSessionKeyRef = useRef(null); + /** + * Tracks the last processed value from `useProjectsState.newSessionTrigger`. + * + * The trigger itself is intentionally increment-only and routed via: + * useProjectsState -> AppContent -> MainContent -> ChatInterface -> this hook. + * We compare values to ensure each explicit New Session click runs exactly one + * reset pass in this local chat state domain. + */ + const previousNewSessionTriggerRef = useRef(newSessionTrigger ?? 0); const createDiff = useMemo(() => createCachedDiffCalculator(), []); + useEffect(() => { + const trigger = newSessionTrigger ?? 0; + if (trigger === previousNewSessionTriggerRef.current) { + return; + } + previousNewSessionTriggerRef.current = trigger; + + /** + * Consumer-side reset for explicit New Session intent. + * + * Why this is essential: + * - Chat keeps local state that is not fully derived from `selectedSession`: + * `currentSessionId`, `pendingUserMessage`, streaming/status flags, message + * pagination/scroll bookkeeping, and pending session IDs in sessionStorage. + * - If the user clicks New Session while already on the same route with no + * selected session, parent state updates can be idempotent and this local + * state would otherwise persist, making the click appear to "do nothing". + * + * What this reset guarantees: + * - A deterministic clean draft state on every New Session click. + * - No dependence on route/tab/session-object identity changes. + * - No coupling to unrelated external update signals. + */ + resetStreamingState(); + pendingViewSessionRef.current = null; + setClaudeStatus(null); + setCanAbortSession(false); + setIsLoading(false); + setCurrentSessionId(null); + setPendingUserMessage(null); + sessionStorage.removeItem('pendingSessionId'); + sessionStorage.removeItem('cursorSessionId'); + messagesOffsetRef.current = 0; + setHasMoreMessages(false); + setTotalMessages(0); + setTokenBudget(null); + setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); + setAllMessagesLoaded(false); + allMessagesLoadedRef.current = false; + setIsLoadingAllMessages(false); + setLoadAllJustFinished(false); + setShowLoadAllOverlay(false); + setViewHiddenCount(0); + setSearchTarget(null); + searchScrollActiveRef.current = false; + topLoadLockRef.current = false; + pendingScrollRestoreRef.current = null; + pendingInitialScrollRef.current = true; + lastLoadedSessionKeyRef.current = null; + + if (loadAllOverlayTimerRef.current) { + clearTimeout(loadAllOverlayTimerRef.current); + loadAllOverlayTimerRef.current = null; + } + if (loadAllFinishedTimerRef.current) { + clearTimeout(loadAllFinishedTimerRef.current); + loadAllFinishedTimerRef.current = null; + } + }, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]); + /* ---------------------------------------------------------------- */ /* Derive chatMessages from the store */ /* ---------------------------------------------------------------- */ const activeSessionId = selectedSession?.id || currentSessionId || null; const [pendingUserMessage, setPendingUserMessage] = useState(null); + const flushedPendingUserMessageRef = useRef(null); // Tell the store which session we're viewing so it only re-renders for this one const prevActiveForStoreRef = useRef(null); @@ -148,17 +222,29 @@ export function useChatSessionState({ sessionStore.setActiveSession(activeSessionId); } - // When a real session ID arrives and we have a pending user message, flush it to the store - const prevActiveSessionRef = useRef(null); - if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) { + useEffect(() => { + if (!pendingUserMessage) { + flushedPendingUserMessageRef.current = null; + return; + } + + if (!activeSessionId) { + return; + } + + if (flushedPendingUserMessageRef.current === pendingUserMessage) { + return; + } + const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov); if (normalized) { sessionStore.appendRealtime(activeSessionId, normalized); } + + flushedPendingUserMessageRef.current = pendingUserMessage; setPendingUserMessage(null); - } - prevActiveSessionRef.current = activeSessionId; + }, [activeSessionId, pendingUserMessage, sessionStore]); const storeMessages = activeSessionId ? sessionStore.getMessages(activeSessionId) : []; diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 526b8cc7..81bd5a5b 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -91,6 +91,10 @@ export interface Question { multiSelect?: boolean; } +export type SessionNavigationOptions = { + replace?: boolean; +}; + export interface ChatInterfaceProps { selectedProject: Project | null; selectedSession: ProjectSession | null; @@ -105,7 +109,7 @@ export interface ChatInterfaceProps { onSessionNotProcessing?: (sessionId?: string | null) => void; processingSessions?: Set; onReplaceTemporarySession?: (sessionId?: string | null) => void; - onNavigateToSession?: (targetSessionId: string) => void; + onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void; onShowSettings?: () => void; autoExpandTools?: boolean; showRawParameters?: boolean; @@ -113,6 +117,7 @@ export interface ChatInterfaceProps { autoScrollToBottom?: boolean; sendByCtrlEnter?: boolean; externalMessageUpdate?: number; + newSessionTrigger?: number; onTaskClick?: (...args: unknown[]) => void; onShowAllTasks?: (() => void) | null; } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 2e923d7a..8589f29a 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -43,6 +43,7 @@ function ChatInterface({ autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, + newSessionTrigger, onShowAllTasks, }: ChatInterfaceProps) { const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); @@ -123,6 +124,7 @@ function ChatInterface({ sendMessage, autoScrollToBottom, externalMessageUpdate, + newSessionTrigger, processingSessions, resetStreamingState, pendingViewSessionRef, diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index d4e708df..d090852d 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -1,5 +1,7 @@ import type { Dispatch, SetStateAction } from 'react'; + import type { AppTab, Project, ProjectSession } from '../../../types/app'; +import type { SessionNavigationOptions } from '../../chat/types/types'; export type SessionLifecycleHandler = (sessionId?: string | null) => void; @@ -50,9 +52,10 @@ export type MainContentProps = { onSessionNotProcessing: SessionLifecycleHandler; processingSessions: Set; onReplaceTemporarySession: SessionLifecycleHandler; - onNavigateToSession: (targetSessionId: string) => void; + onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void; onShowSettings: () => void; externalMessageUpdate: number; + newSessionTrigger: number; }; export type MainContentHeaderProps = { diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index a86dcbc9..1a9c7349 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -51,6 +51,7 @@ function MainContent({ onNavigateToSession, onShowSettings, externalMessageUpdate, + newSessionTrigger, }: MainContentProps) { const { preferences } = useUiPreferences(); const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences; @@ -145,6 +146,7 @@ function MainContent({ autoScrollToBottom={autoScrollToBottom} sendByCtrlEnter={sendByCtrlEnter} externalMessageUpdate={externalMessageUpdate} + newSessionTrigger={newSessionTrigger} onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null} /> diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index c1c9344c..d920fba2 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -5,6 +5,7 @@ import { api } from '../utils/api'; import type { AppSocketMessage, AppTab, + LLMProvider, LoadingProgress, Project, ProjectSession, @@ -261,6 +262,27 @@ export function useProjectsState({ const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); + /** + * `newSessionTrigger` is an explicit, monotonic intent signal for user-driven + * New Session actions. + * + * It exists because `handleNewSession` can be invoked while the app is already in + * the same visible state (`selectedSession === null`, `activeTab === 'chat'`, + * route already `/`). In that case, React/router updates are idempotent and no + * downstream reset logic runs. + * + * Usage across the codebase: + * 1) Produced here in `handleNewSession` via increment (always changes). + * 2) Returned from this hook and threaded through: + * useProjectsState -> AppContent -> MainContent -> ChatInterface. + * 3) Consumed in `useChatSessionState` as an effect dependency to forcibly clear + * chat-local state (`currentSessionId`, pending draft message, streaming flags, + * pending session storage keys, pagination/scroll artifacts). + * + * Keeping this signal dedicated avoids coupling resets to unrelated counters/events + * (for example websocket/project refresh updates) that could cause accidental resets. + */ + const [newSessionTrigger, setNewSessionTrigger] = useState(0); const loadingProgressTimeoutRef = useRef | null>(null); const lastHandledMessageRef = useRef(null); @@ -536,7 +558,42 @@ export function useProjectsState({ return; } } - }, [sessionId, projects, selectedProject?.projectId, selectedSession?.id, selectedSession?.__provider]); + + // Session id is in the URL but not yet present on any project payload (common + // right after `session_created` + navigate, before the next projects refresh). + // Without a `selectedSession`, chat state clears `currentSessionId` and the + // UI stops reading the session store even though messages stream under this id. + if (selectedSession?.id === sessionId) { + return; + } + + if (!selectedProject) { + return; + } + + let providerFromStorage: string | null = null; + try { + providerFromStorage = localStorage.getItem('selected-provider'); + } catch { + providerFromStorage = null; + } + + const normalizedProvider: LLMProvider = + providerFromStorage === 'cursor' + ? 'cursor' + : providerFromStorage === 'codex' + ? 'codex' + : providerFromStorage === 'gemini' + ? 'gemini' + : 'claude'; + + setSelectedSession({ + id: sessionId, + __provider: normalizedProvider, + __projectId: selectedProject.projectId, + summary: '', + }); + }, [sessionId, projects, selectedProject, selectedSession?.id, selectedSession?.__provider]); const handleProjectSelect = useCallback( (project: Project) => { @@ -587,6 +644,7 @@ export function useProjectsState({ setSelectedProject(project); setSelectedSession(null); setActiveTab('chat'); + setNewSessionTrigger((previous) => previous + 1); navigate('/'); if (isMobile) { @@ -806,6 +864,7 @@ export function useProjectsState({ showSettings, settingsInitialTab, externalMessageUpdate, + newSessionTrigger, setActiveTab, setSidebarOpen, setIsInputFocused, diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index ef581e12..86925048 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -104,17 +104,126 @@ function createEmptySlot(): SessionSlot { } /** - * Compute merged messages: server + realtime, deduped by id. - * Server messages take priority (they're the persisted source of truth). - * Realtime messages that aren't yet in server stay (in-flight streaming). + * Compute merged messages: server + realtime, deduped by id and adjacent + * assistant echo (same trimmed text), so finalized stream rows do not stack + * on top of the persisted copy before realtime is cleared. */ +function userTextFingerprint(m: NormalizedMessage): string | null { + if (m.kind !== 'text' || m.role !== 'user') return null; + const t = (m.content || '').trim(); + return t.length > 0 ? t : null; +} + +/** + * After `finalizeStreaming`, the client holds a synthetic assistant `text` row + * while the sessions API soon returns the same reply with a different id. + * Those sit back-to-back in merged order and look like duplicate bubbles until + * `refreshFromServer` clears realtime. Collapse same-text assistant rows and + * stream_placeholder → text when content matches. + */ +function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedMessage[] { + const out: NormalizedMessage[] = []; + for (const m of merged) { + const prev = out[out.length - 1]; + if (prev) { + if (prev.kind === 'stream_delta' && m.kind === 'text' && m.role === 'assistant') { + const ps = (prev.content || '').trim(); + const ms = (m.content || '').trim(); + if (ps.length > 0 && ps === ms) { + out[out.length - 1] = m; + continue; + } + } + if ( + prev.kind === 'text' + && m.kind === 'text' + && prev.role === 'assistant' + && m.role === 'assistant' + ) { + const ms = (m.content || '').trim(); + if (ms.length > 0 && ms === (prev.content || '').trim()) { + continue; + } + } + } + out.push(m); + } + return out; +} + function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] { if (realtime.length === 0) return server; - if (server.length === 0) return realtime; + if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime); const serverIds = new Set(server.map(m => m.id)); - const extra = realtime.filter(m => !serverIds.has(m.id)); + const serverUserTexts = new Set( + server.map(userTextFingerprint).filter((t): t is string => t !== null), + ); + const extra = realtime.filter((m) => { + if (serverIds.has(m.id)) return false; + // Optimistic user rows use `local_*` ids; once the same text exists on the + // server-backed copy, drop the realtime echo to avoid duplicate bubbles. + if (m.id.startsWith('local_')) { + const fp = userTextFingerprint(m); + if (fp && serverUserTexts.has(fp)) return false; + } + return true; + }); if (extra.length === 0) return server; - return [...server, ...extra]; + return dedupeAdjacentAssistantEchoes([...server, ...extra]); +} + +function compareMessagesByTimestamp(left: NormalizedMessage, right: NormalizedMessage): number { + const leftTime = Date.parse(left.timestamp); + const rightTime = Date.parse(right.timestamp); + + if (Number.isNaN(leftTime) || Number.isNaN(rightTime) || leftTime === rightTime) { + return 0; + } + + return leftTime - rightTime; +} + +function rewriteMessageSessionId( + msg: NormalizedMessage, + fromSessionId: string, + toSessionId: string, +): NormalizedMessage { + const streamingSourceId = `__streaming_${fromSessionId}`; + const nextId = msg.id === streamingSourceId ? `__streaming_${toSessionId}` : msg.id; + + if (msg.sessionId === toSessionId && nextId === msg.id) { + return msg; + } + + return { + ...msg, + id: nextId, + sessionId: toSessionId, + }; +} + +function mergeMessagesById( + existing: NormalizedMessage[], + incoming: NormalizedMessage[], +): NormalizedMessage[] { + if (existing.length === 0) return incoming; + if (incoming.length === 0) return existing; + + const merged = [...existing, ...incoming]; + const deduped: NormalizedMessage[] = []; + const seen = new Set(); + + for (const msg of merged) { + if (seen.has(msg.id)) { + continue; + } + + seen.add(msg.id); + deduped.push(msg); + } + + deduped.sort(compareMessagesByTimestamp); + return deduped; } /** @@ -141,28 +250,59 @@ const MAX_REALTIME_MESSAGES = 500; export function useSessionStore() { const storeRef = useRef(new Map()); + const sessionAliasesRef = useRef(new Map()); const activeSessionIdRef = useRef(null); // Bump to force re-render — only when the active session's data changes const [, setTick] = useState(0); const notify = useCallback((sessionId: string) => { - if (sessionId === activeSessionIdRef.current) { + const aliases = sessionAliasesRef.current; + let resolvedSessionId = sessionId; + const visited = new Set(); + + while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) { + visited.add(resolvedSessionId); + resolvedSessionId = aliases.get(resolvedSessionId)!; + } + + if (resolvedSessionId === activeSessionIdRef.current) { setTick(n => n + 1); } }, []); - const setActiveSession = useCallback((sessionId: string | null) => { - activeSessionIdRef.current = sessionId; + const resolveSessionId = useCallback((sessionId: string | null | undefined): string | null => { + if (!sessionId) { + return null; + } + + const aliases = sessionAliasesRef.current; + let resolvedSessionId = sessionId; + const visited = new Set(); + + while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) { + visited.add(resolvedSessionId); + resolvedSessionId = aliases.get(resolvedSessionId)!; + } + + return resolvedSessionId; }, []); + const setActiveSession = useCallback((sessionId: string | null) => { + activeSessionIdRef.current = resolveSessionId(sessionId); + }, [resolveSessionId]); + const getSlot = useCallback((sessionId: string): SessionSlot => { + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; const store = storeRef.current; - if (!store.has(sessionId)) { - store.set(sessionId, createEmptySlot()); + if (!store.has(resolvedSessionId)) { + store.set(resolvedSessionId, createEmptySlot()); } - return store.get(sessionId)!; - }, []); + return store.get(resolvedSessionId)!; + }, [resolveSessionId]); - const has = useCallback((sessionId: string) => storeRef.current.has(sessionId), []); + const has = useCallback((sessionId: string) => { + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + return storeRef.current.has(resolvedSessionId); + }, [resolveSessionId]); /** * Fetch messages from the provider sessions endpoint and populate serverMessages. @@ -179,9 +319,10 @@ export function useSessionStore() { offset?: number; } = {}, ) => { - const slot = getSlot(sessionId); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = getSlot(resolvedSessionId); slot.status = 'loading'; - notify(sessionId); + notify(resolvedSessionId); try { const params = new URLSearchParams(); @@ -191,7 +332,7 @@ export function useSessionStore() { } const qs = params.toString(); - const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`; const response = await authenticatedFetch(url); if (!response.ok) { @@ -212,15 +353,15 @@ export function useSessionStore() { slot.tokenUsage = data.tokenUsage; } - notify(sessionId); + notify(resolvedSessionId); return slot; } catch (error) { - console.error(`[SessionStore] fetch failed for ${sessionId}:`, error); + console.error(`[SessionStore] fetch failed for ${resolvedSessionId}:`, error); slot.status = 'error'; - notify(sessionId); + notify(resolvedSessionId); return slot; } - }, [getSlot, notify]); + }, [getSlot, notify, resolveSessionId]); /** * Load older (paginated) messages and prepend to serverMessages. @@ -234,7 +375,8 @@ export function useSessionStore() { limit?: number; } = {}, ) => { - const slot = getSlot(sessionId); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = getSlot(resolvedSessionId); if (!slot.hasMore) return slot; const params = new URLSearchParams(); @@ -243,7 +385,7 @@ export function useSessionStore() { params.append('offset', String(slot.offset)); const qs = params.toString(); - const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`; try { const response = await authenticatedFetch(url); @@ -256,43 +398,54 @@ export function useSessionStore() { slot.hasMore = Boolean(data.hasMore); slot.offset = slot.offset + olderMessages.length; recomputeMergedIfNeeded(slot); - notify(sessionId); + notify(resolvedSessionId); return slot; } catch (error) { - console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error); + console.error(`[SessionStore] fetchMore failed for ${resolvedSessionId}:`, error); return slot; } - }, [getSlot, notify]); + }, [getSlot, notify, resolveSessionId]); /** * Append a realtime (WebSocket) message to the correct session slot. * This works regardless of which session is actively viewed. */ const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => { - const slot = getSlot(sessionId); - let updated = [...slot.realtimeMessages, msg]; + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = getSlot(resolvedSessionId); + const normalizedMessage = + msg.sessionId === resolvedSessionId + ? msg + : { ...msg, sessionId: resolvedSessionId }; + let updated = [...slot.realtimeMessages, normalizedMessage]; if (updated.length > MAX_REALTIME_MESSAGES) { updated = updated.slice(-MAX_REALTIME_MESSAGES); } slot.realtimeMessages = updated; recomputeMergedIfNeeded(slot); - notify(sessionId); - }, [getSlot, notify]); + notify(resolvedSessionId); + }, [getSlot, notify, resolveSessionId]); /** * Append multiple realtime messages at once (batch). */ const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => { if (msgs.length === 0) return; - const slot = getSlot(sessionId); - let updated = [...slot.realtimeMessages, ...msgs]; + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = getSlot(resolvedSessionId); + const normalizedMessages = msgs.map((msg) => + msg.sessionId === resolvedSessionId + ? msg + : { ...msg, sessionId: resolvedSessionId }, + ); + let updated = [...slot.realtimeMessages, ...normalizedMessages]; if (updated.length > MAX_REALTIME_MESSAGES) { updated = updated.slice(-MAX_REALTIME_MESSAGES); } slot.realtimeMessages = updated; recomputeMergedIfNeeded(slot); - notify(sessionId); - }, [getSlot, notify]); + notify(resolvedSessionId); + }, [getSlot, notify, resolveSessionId]); /** * Re-fetch serverMessages from the provider sessions endpoint. @@ -305,12 +458,13 @@ export function useSessionStore() { projectPath?: string; } = {}, ) => { - const slot = getSlot(sessionId); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = getSlot(resolvedSessionId); try { const params = new URLSearchParams(); const qs = params.toString(); - const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`; const response = await authenticatedFetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -323,40 +477,43 @@ export function useSessionStore() { // drop realtime messages that the server has caught up with to prevent unbounded growth. slot.realtimeMessages = []; recomputeMergedIfNeeded(slot); - notify(sessionId); + notify(resolvedSessionId); } catch (error) { - console.error(`[SessionStore] refresh failed for ${sessionId}:`, error); + console.error(`[SessionStore] refresh failed for ${resolvedSessionId}:`, error); } - }, [getSlot, notify]); + }, [getSlot, notify, resolveSessionId]); /** * Update session status. */ const setStatus = useCallback((sessionId: string, status: SessionStatus) => { - const slot = getSlot(sessionId); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = getSlot(resolvedSessionId); slot.status = status; - notify(sessionId); - }, [getSlot, notify]); + notify(resolvedSessionId); + }, [getSlot, notify, resolveSessionId]); /** * Check if a session's data is stale (>30s old). */ const isStale = useCallback((sessionId: string) => { - const slot = storeRef.current.get(sessionId); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = storeRef.current.get(resolvedSessionId); if (!slot) return true; return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS; - }, []); + }, [resolveSessionId]); /** * Update or create a streaming message (accumulated text so far). * Uses a well-known ID so subsequent calls replace the same message. */ const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: LLMProvider) => { - const slot = getSlot(sessionId); - const streamId = `__streaming_${sessionId}`; + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = getSlot(resolvedSessionId); + const streamId = `__streaming_${resolvedSessionId}`; const msg: NormalizedMessage = { id: streamId, - sessionId, + sessionId: resolvedSessionId, timestamp: new Date().toISOString(), provider: msgProvider, kind: 'stream_delta', @@ -370,17 +527,18 @@ export function useSessionStore() { slot.realtimeMessages = [...slot.realtimeMessages, msg]; } recomputeMergedIfNeeded(slot); - notify(sessionId); - }, [getSlot, notify]); + notify(resolvedSessionId); + }, [getSlot, notify, resolveSessionId]); /** * Finalize streaming: convert the streaming message to a regular text message. * The well-known streaming ID is replaced with a unique text message ID. */ const finalizeStreaming = useCallback((sessionId: string) => { - const slot = storeRef.current.get(sessionId); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = storeRef.current.get(resolvedSessionId); if (!slot) return; - const streamId = `__streaming_${sessionId}`; + const streamId = `__streaming_${resolvedSessionId}`; const idx = slot.realtimeMessages.findIndex(m => m.id === streamId); if (idx >= 0) { const stream = slot.realtimeMessages[idx]; @@ -392,35 +550,104 @@ export function useSessionStore() { role: 'assistant', }; recomputeMergedIfNeeded(slot); - notify(sessionId); + notify(resolvedSessionId); } - }, [notify]); + }, [notify, resolveSessionId]); /** * Clear realtime messages for a session (e.g., after stream completes and server fetch catches up). */ const clearRealtime = useCallback((sessionId: string) => { - const slot = storeRef.current.get(sessionId); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + const slot = storeRef.current.get(resolvedSessionId); if (slot) { slot.realtimeMessages = []; recomputeMergedIfNeeded(slot); - notify(sessionId); + notify(resolvedSessionId); } - }, [notify]); + }, [notify, resolveSessionId]); /** * Get merged messages for a session (for rendering). */ const getMessages = useCallback((sessionId: string): NormalizedMessage[] => { - return storeRef.current.get(sessionId)?.merged ?? []; - }, []); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + return storeRef.current.get(resolvedSessionId)?.merged ?? []; + }, [resolveSessionId]); /** * Get session slot (for status, pagination info, etc.). */ const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => { - return storeRef.current.get(sessionId); - }, []); + const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId; + return storeRef.current.get(resolvedSessionId); + }, [resolveSessionId]); + + const replaceSessionId = useCallback((fromSessionId: string, toSessionId: string) => { + const resolvedFromSessionId = resolveSessionId(fromSessionId) ?? fromSessionId; + const resolvedToSessionId = resolveSessionId(toSessionId) ?? toSessionId; + + if (resolvedFromSessionId === resolvedToSessionId) { + sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId); + return; + } + + const store = storeRef.current; + const sourceSlot = store.get(resolvedFromSessionId); + const targetSlot = store.get(resolvedToSessionId) ?? createEmptySlot(); + + if (sourceSlot) { + const migratedServerMessages = sourceSlot.serverMessages.map((msg) => + rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId), + ); + const migratedRealtimeMessages = sourceSlot.realtimeMessages.map((msg) => + rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId), + ); + + targetSlot.serverMessages = mergeMessagesById(targetSlot.serverMessages, migratedServerMessages); + targetSlot.realtimeMessages = mergeMessagesById(targetSlot.realtimeMessages, migratedRealtimeMessages); + if (targetSlot.realtimeMessages.length > MAX_REALTIME_MESSAGES) { + targetSlot.realtimeMessages = targetSlot.realtimeMessages.slice(-MAX_REALTIME_MESSAGES); + } + targetSlot.status = + sourceSlot.status === 'error' + ? 'error' + : sourceSlot.status === 'streaming' || targetSlot.status === 'streaming' + ? 'streaming' + : sourceSlot.status === 'loading' || targetSlot.status === 'loading' + ? 'loading' + : targetSlot.status; + targetSlot.fetchedAt = Math.max(targetSlot.fetchedAt, sourceSlot.fetchedAt, Date.now()); + targetSlot.total = Math.max( + targetSlot.total, + sourceSlot.total, + targetSlot.serverMessages.length, + targetSlot.realtimeMessages.length, + ); + targetSlot.hasMore = targetSlot.hasMore || sourceSlot.hasMore; + targetSlot.offset = Math.max(targetSlot.offset, sourceSlot.offset); + targetSlot.tokenUsage = targetSlot.tokenUsage ?? sourceSlot.tokenUsage; + recomputeMergedIfNeeded(targetSlot); + + store.set(resolvedToSessionId, targetSlot); + store.delete(resolvedFromSessionId); + } + + sessionAliasesRef.current.set(resolvedFromSessionId, resolvedToSessionId); + sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId); + + for (const [aliasSessionId, targetSessionId] of sessionAliasesRef.current.entries()) { + if (targetSessionId === resolvedFromSessionId) { + sessionAliasesRef.current.set(aliasSessionId, resolvedToSessionId); + } + } + + if (activeSessionIdRef.current === resolvedFromSessionId) { + activeSessionIdRef.current = resolvedToSessionId; + } + + notify(resolvedToSessionId); + }, [notify, resolveSessionId]); return useMemo(() => ({ getSlot, @@ -438,11 +665,12 @@ export function useSessionStore() { clearRealtime, getMessages, getSessionSlot, + replaceSessionId, }), [ getSlot, has, fetchFromServer, fetchMore, appendRealtime, appendRealtimeBatch, refreshFromServer, setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, - clearRealtime, getMessages, getSessionSlot, + clearRealtime, getMessages, getSessionSlot, replaceSessionId, ]); }