From 1998989d90dcaf3c5e890a5fc30c2a890c179ae6 Mon Sep 17 00:00:00 2001 From: bbsngg Date: Wed, 15 Apr 2026 15:07:04 -0400 Subject: [PATCH] feat: integrate session scope tracking into frontend state management Upgrade frontend session tracking from plain sessionId to composite project::provider::session scope keys for multi-provider correctness. - chatStorage: add provider-scoped storage functions (persistScopedPendingSessionId, buildChatMessagesStorageKey, etc.) - useSessionProtection: tracking callbacks now accept (sessionId, provider, projectName) and use scope keys internally - projectsSessionSync (new): provider-to-session-array mapping, upsertProviderSessionList for optimistic UI updates - useProjectsState: integrate projectsSessionSync, listen for optimistic-session-created custom events Co-Authored-By: Claude Opus 4.6 --- src/components/chat/utils/chatStorage.ts | 130 +++++++++- .../__tests__/projectsSessionSync.test.ts | 175 +++++++++++++ src/hooks/projectsSessionSync.ts | 239 ++++++++++++++++++ src/hooks/useProjectsState.ts | 191 +++++++++++--- src/hooks/useSessionProtection.ts | 166 ++++++++++-- 5 files changed, 838 insertions(+), 63 deletions(-) create mode 100644 src/hooks/__tests__/projectsSessionSync.test.ts create mode 100644 src/hooks/projectsSessionSync.ts diff --git a/src/components/chat/utils/chatStorage.ts b/src/components/chat/utils/chatStorage.ts index f21c79bb..e9d8b7f5 100644 --- a/src/components/chat/utils/chatStorage.ts +++ b/src/components/chat/utils/chatStorage.ts @@ -1,4 +1,6 @@ import type { ProviderSettings } from '../types/types'; +import type { SessionProvider } from '../../../types/app'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../../../utils/providerPolicy'; export const CLAUDE_SETTINGS_KEY = 'claude-settings'; export const GEMINI_SETTINGS_KEY = 'gemini-settings'; @@ -6,6 +8,10 @@ export const CURSOR_SETTINGS_KEY = 'cursor-tools-settings'; export const CODEX_SETTINGS_KEY = 'codex-settings'; export const NANO_SETTINGS_KEY = 'nano-claude-code-settings'; const SESSION_TIMER_PREFIX = 'session_timer_start_'; +const CHAT_MESSAGES_PREFIX = 'chat_messages_'; +const DRAFT_INPUT_PREFIX = 'draft_input_'; +const SCOPED_PENDING_SESSION_PREFIX = 'pending_session_id_'; +const SCOPED_PROVIDER_SESSION_PREFIX = 'provider_session_id_'; const safeSessionStorage = { setItem: (key: string, value: string) => { @@ -42,10 +48,56 @@ export function getProviderSettingsKey(provider?: string) { } } +function normalizeScopedStorageProvider( + provider?: SessionProvider | string | null, +): SessionProvider { + return normalizeProvider((provider || DEFAULT_PROVIDER) as SessionProvider); +} + +export function buildChatMessagesStorageKey( + projectName: string | null | undefined, + sessionId: string | null | undefined, + provider?: SessionProvider | string | null, +) { + if (!projectName || !sessionId) { + return ''; + } + + const normalizedProvider = normalizeScopedStorageProvider(provider); + return `${CHAT_MESSAGES_PREFIX}${projectName}_${normalizedProvider}_${sessionId}`; +} + +export function buildDraftInputStorageKey( + projectName: string | null | undefined, + provider?: SessionProvider | string | null, + sessionOrBucket: string | null | undefined = 'new', +) { + if (!projectName) { + return ''; + } + + const normalizedProvider = normalizeScopedStorageProvider(provider); + const normalizedBucket = sessionOrBucket || 'new'; + return `${DRAFT_INPUT_PREFIX}${projectName}_${normalizedProvider}_${normalizedBucket}`; +} + +function buildScopedSessionStorageKey( + prefix: string, + projectName: string | null | undefined, + provider?: SessionProvider | string | null, +) { + if (!projectName) { + return ''; + } + + const normalizedProvider = normalizeScopedStorageProvider(provider); + return `${prefix}${projectName}_${normalizedProvider}`; +} + export const safeLocalStorage = { setItem: (key: string, value: string) => { try { - if (key.startsWith('chat_messages_') && typeof value === 'string') { + if (key.startsWith(CHAT_MESSAGES_PREFIX) && typeof value === 'string') { try { const parsed = JSON.parse(value); if (Array.isArray(parsed) && parsed.length > 50) { @@ -80,7 +132,7 @@ export const safeLocalStorage = { localStorage.setItem(key, value); } catch (retryError) { console.error('Failed to save to localStorage even after cleanup:', retryError); - if (key.startsWith('chat_messages_') && typeof value === 'string') { + if (key.startsWith(CHAT_MESSAGES_PREFIX) && typeof value === 'string') { try { const parsed = JSON.parse(value); if (Array.isArray(parsed) && parsed.length > 10) { @@ -122,6 +174,80 @@ export function persistSessionTimerStart(sessionId: string | null | undefined, s safeSessionStorage.setItem(`${SESSION_TIMER_PREFIX}${sessionId}`, String(startTime)); } +export function persistScopedPendingSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, + sessionId: string | null | undefined, +) { + const storageKey = buildScopedSessionStorageKey(SCOPED_PENDING_SESSION_PREFIX, projectName, provider); + if (!storageKey || !sessionId) { + return; + } + + safeSessionStorage.setItem(storageKey, sessionId); +} + +export function readScopedPendingSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, +): string | null { + const storageKey = buildScopedSessionStorageKey(SCOPED_PENDING_SESSION_PREFIX, projectName, provider); + if (!storageKey) { + return null; + } + + return safeSessionStorage.getItem(storageKey); +} + +export function clearScopedPendingSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, +) { + const storageKey = buildScopedSessionStorageKey(SCOPED_PENDING_SESSION_PREFIX, projectName, provider); + if (!storageKey) { + return; + } + + safeSessionStorage.removeItem(storageKey); +} + +export function persistScopedProviderSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, + sessionId: string | null | undefined, +) { + const storageKey = buildScopedSessionStorageKey(SCOPED_PROVIDER_SESSION_PREFIX, projectName, provider); + if (!storageKey || !sessionId) { + return; + } + + safeSessionStorage.setItem(storageKey, sessionId); +} + +export function readScopedProviderSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, +): string | null { + const storageKey = buildScopedSessionStorageKey(SCOPED_PROVIDER_SESSION_PREFIX, projectName, provider); + if (!storageKey) { + return null; + } + + return safeSessionStorage.getItem(storageKey); +} + +export function clearScopedProviderSessionId( + projectName: string | null | undefined, + provider: SessionProvider | string | null | undefined, +) { + const storageKey = buildScopedSessionStorageKey(SCOPED_PROVIDER_SESSION_PREFIX, projectName, provider); + if (!storageKey) { + return; + } + + safeSessionStorage.removeItem(storageKey); +} + export function readSessionTimerStart(sessionId: string | null | undefined): number | null { if (!sessionId) { return null; diff --git a/src/hooks/__tests__/projectsSessionSync.test.ts b/src/hooks/__tests__/projectsSessionSync.test.ts new file mode 100644 index 00000000..144958a6 --- /dev/null +++ b/src/hooks/__tests__/projectsSessionSync.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; + +import type { Project } from '../../types/app'; +import { + hasTrackedTemporarySession, + isTrackedSessionActive, + resolveProjectSessionArrayKey, + upsertProjectSession, +} from '../projectsSessionSync'; + +function buildBaseProject(overrides: Partial = {}): Project { + return { + name: 'proj-a', + displayName: 'proj-a', + fullPath: 'C:\\proj-a', + sessions: [], + cursorSessions: [], + codexSessions: [], + geminiSessions: [], + openrouterSessions: [], + localSessions: [], + nanoSessions: [], + ...overrides, + }; +} + +describe('projectsSessionSync', () => { + it('upserts sessions into the correct list for every supported provider', () => { + const providerCases = [ + ['claude', 'sessions'], + ['cursor', 'cursorSessions'], + ['codex', 'codexSessions'], + ['gemini', 'geminiSessions'], + ['openrouter', 'openrouterSessions'], + ['local', 'localSessions'], + ['nano', 'nanoSessions'], + ] as const; + + for (const [provider, targetKey] of providerCases) { + const project = buildBaseProject(); + const next = upsertProjectSession(project, { + projectName: 'proj-a', + provider, + sessionId: `${provider}-session-1`, + mode: 'research', + displayName: `${provider}-session`, + createdAt: '2026-04-12T15:00:00.000Z', + }); + + expect(next[targetKey]).toHaveLength(1); + expect(next[targetKey]?.[0]).toEqual( + expect.objectContaining({ + id: `${provider}-session-1`, + __provider: provider, + __projectName: 'proj-a', + }), + ); + } + }); + + it('resolves project session array keys for all providers', () => { + expect(resolveProjectSessionArrayKey('claude')).toBe('sessions'); + expect(resolveProjectSessionArrayKey('cursor')).toBe('cursorSessions'); + expect(resolveProjectSessionArrayKey('codex')).toBe('codexSessions'); + expect(resolveProjectSessionArrayKey('gemini')).toBe('geminiSessions'); + expect(resolveProjectSessionArrayKey('openrouter')).toBe('openrouterSessions'); + expect(resolveProjectSessionArrayKey('local')).toBe('localSessions'); + expect(resolveProjectSessionArrayKey('nano')).toBe('nanoSessions'); + }); + + it('injects optimistic sessions immediately into the matching provider list', () => { + const project = buildBaseProject(); + const next = upsertProjectSession(project, { + projectName: 'proj-a', + provider: 'codex', + sessionId: 'new-session-123', + mode: 'research', + displayName: 'Optimistic Session', + createdAt: '2026-04-12T15:00:00.000Z', + }); + + expect(next.codexSessions).toHaveLength(1); + expect(next.codexSessions?.[0]).toEqual( + expect.objectContaining({ + id: 'new-session-123', + summary: 'Optimistic Session', + __provider: 'codex', + __projectName: 'proj-a', + }), + ); + }); + + it('replaces temporary optimistic session identity with settled session id', () => { + const project = buildBaseProject({ + codexSessions: [ + { + id: 'new-session-123', + summary: 'Temporary', + __provider: 'codex', + __projectName: 'proj-a', + }, + ], + }); + + const next = upsertProjectSession(project, { + projectName: 'proj-a', + provider: 'codex', + sessionId: '019d82e8-1ee3-7860-baa1-24603f424ade', + temporarySessionId: 'new-session-123', + mode: 'research', + displayName: 'Settled Session', + createdAt: '2026-04-12T15:01:00.000Z', + }); + + expect(next.codexSessions).toHaveLength(1); + expect(next.codexSessions?.[0].id).toBe('019d82e8-1ee3-7860-baa1-24603f424ade'); + expect(next.codexSessions?.[0].summary).toBe('Settled Session'); + }); + + it('treats same session id under different providers as separate identities', () => { + const project = buildBaseProject({ + codexSessions: [ + { + id: 'sess-1', + summary: 'Codex', + __provider: 'codex', + __projectName: 'proj-a', + }, + ], + }); + + const next = upsertProjectSession(project, { + projectName: 'proj-a', + provider: 'gemini', + sessionId: 'sess-1', + mode: 'research', + displayName: 'Gemini', + createdAt: '2026-04-12T15:02:00.000Z', + }); + + expect(next.codexSessions).toHaveLength(1); + expect(next.geminiSessions).toHaveLength(1); + expect(next.codexSessions?.[0].id).toBe('sess-1'); + expect(next.geminiSessions?.[0].id).toBe('sess-1'); + }); + + it('matches active sessions from scoped tracking keys', () => { + const activeSessions = new Set([ + 'proj-a::codex::sess-1', + 'proj-b::gemini::sess-2', + ]); + + expect( + isTrackedSessionActive(activeSessions, { + sessionId: 'sess-1', + provider: 'codex', + projectName: 'proj-a', + }), + ).toBe(true); + + expect( + isTrackedSessionActive(activeSessions, { + sessionId: 'sess-1', + provider: 'gemini', + projectName: 'proj-a', + }), + ).toBe(false); + }); + + it('detects temporary sessions from both raw and scoped tracking keys', () => { + expect(hasTrackedTemporarySession(new Set(['new-session-1']))).toBe(true); + expect(hasTrackedTemporarySession(new Set(['proj-a::codex::new-session-2']))).toBe(true); + expect(hasTrackedTemporarySession(new Set(['proj-a::codex::sess-1']))).toBe(false); + }); +}); diff --git a/src/hooks/projectsSessionSync.ts b/src/hooks/projectsSessionSync.ts new file mode 100644 index 00000000..61aa4373 --- /dev/null +++ b/src/hooks/projectsSessionSync.ts @@ -0,0 +1,239 @@ +import type { + Project, + ProjectSession, + SessionMode, + SessionProvider, +} from '../types/app'; +import { DEFAULT_PROVIDER, normalizeProvider } from '../utils/providerPolicy'; +import { parseSessionScopeKey } from '../utils/sessionScope'; + +export type ProjectSessionArrayKey = + | 'sessions' + | 'cursorSessions' + | 'codexSessions' + | 'geminiSessions' + | 'openrouterSessions' + | 'localSessions' + | 'nanoSessions'; + +export const FALLBACK_SESSION_NAME_BY_PROVIDER: Record = { + claude: 'New Session', + cursor: 'Untitled Session', + codex: 'Codex Session', + gemini: 'Gemini Session', + openrouter: 'OpenRouter Session', + local: 'Local GPU Session', + nano: 'Nano Claude Code Session', +}; + +export function resolveProjectSessionArrayKey( + provider: SessionProvider | string | null | undefined, +): ProjectSessionArrayKey | null { + const normalizedProvider = normalizeProvider((provider || DEFAULT_PROVIDER) as SessionProvider); + switch (normalizedProvider) { + case 'claude': + return 'sessions'; + case 'cursor': + return 'cursorSessions'; + case 'codex': + return 'codexSessions'; + case 'gemini': + return 'geminiSessions'; + case 'openrouter': + return 'openrouterSessions'; + case 'local': + return 'localSessions'; + case 'nano': + return 'nanoSessions'; + default: + return null; + } +} + +function sessionsMatchIdentity( + session: ProjectSession, + provider: SessionProvider, + sessionId: string, +): boolean { + return ( + session.id === sessionId && + normalizeProvider((session.__provider || provider) as SessionProvider) === provider + ); +} + +type UpsertProviderSessionOptions = { + provider: SessionProvider; + sessionId: string; + projectName: string; + mode: SessionMode; + displayName?: string; + createdAt?: string; + temporarySessionId?: string | null; +}; + +export function upsertProviderSessionList( + sessions: ProjectSession[] | undefined, + options: UpsertProviderSessionOptions, +): ProjectSession[] { + const currentSessions = Array.isArray(sessions) ? sessions : []; + const { + provider, + sessionId, + projectName, + mode, + displayName, + createdAt, + temporarySessionId = null, + } = options; + const timestamp = createdAt || new Date().toISOString(); + const fallbackName = FALLBACK_SESSION_NAME_BY_PROVIDER[provider] || 'New Session'; + const summary = displayName || fallbackName; + + let hasTargetSession = false; + const nextSessions: ProjectSession[] = []; + + for (const session of currentSessions) { + if ( + temporarySessionId && + session.id === temporarySessionId && + normalizeProvider((session.__provider || provider) as SessionProvider) === provider + ) { + continue; + } + + if (!sessionsMatchIdentity(session, provider, sessionId)) { + nextSessions.push(session); + continue; + } + + if (hasTargetSession) { + // Drop accidental duplicates while preserving the first canonical item. + continue; + } + + hasTargetSession = true; + nextSessions.push({ + ...session, + id: sessionId, + name: displayName || session.name || summary, + summary: displayName || session.summary || summary, + mode: mode || session.mode || 'research', + __provider: provider, + __projectName: projectName, + createdAt: session.createdAt || timestamp, + lastActivity: timestamp, + }); + } + + if (!hasTargetSession) { + nextSessions.unshift({ + id: sessionId, + name: summary, + summary, + mode, + __provider: provider, + __projectName: projectName, + createdAt: timestamp, + lastActivity: timestamp, + }); + } + + return nextSessions; +} + +type UpsertProjectSessionOptions = Omit & { + provider: SessionProvider | string | null | undefined; +}; + +export function upsertProjectSession( + project: Project, + options: UpsertProjectSessionOptions, +): Project { + const normalizedProvider = normalizeProvider( + (options.provider || DEFAULT_PROVIDER) as SessionProvider, + ); + const sessionArrayKey = resolveProjectSessionArrayKey(normalizedProvider); + if (!sessionArrayKey) { + return project; + } + + const nextSessions = upsertProviderSessionList( + project[sessionArrayKey] as ProjectSession[] | undefined, + { + ...options, + provider: normalizedProvider, + }, + ); + + const currentSessions = project[sessionArrayKey] as ProjectSession[] | undefined; + if (currentSessions === nextSessions) { + return project; + } + + return { + ...project, + [sessionArrayKey]: nextSessions, + }; +} + +function getScopedTrackingSessionId(trackingKey: string): string { + if (!trackingKey) { + return ''; + } + const parsed = parseSessionScopeKey(trackingKey); + return parsed?.sessionId || trackingKey; +} + +export function hasTrackedTemporarySession(activeSessions: Set): boolean { + for (const trackingKey of activeSessions) { + const sessionId = getScopedTrackingSessionId(trackingKey); + if (sessionId.startsWith('new-session-') || sessionId.startsWith('temp-')) { + return true; + } + } + return false; +} + +type TrackedSessionIdentity = { + sessionId: string; + provider?: SessionProvider | string | null; + projectName?: string | null; +}; + +export function isTrackedSessionActive( + activeSessions: Set, + identity: TrackedSessionIdentity | null | undefined, +): boolean { + if (!identity?.sessionId) { + return false; + } + + const sessionId = identity.sessionId; + const normalizedProvider = identity.provider + ? normalizeProvider(identity.provider as SessionProvider) + : null; + const normalizedProjectName = identity.projectName || null; + + for (const trackingKey of activeSessions) { + if (trackingKey === sessionId) { + return true; + } + + const parsed = parseSessionScopeKey(trackingKey); + if (!parsed || parsed.sessionId !== sessionId) { + continue; + } + + if (normalizedProjectName && parsed.projectName !== normalizedProjectName) { + continue; + } + + if (normalizedProvider && parsed.provider !== normalizedProvider) { + continue; + } + + return true; + } + + return false; +} diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index a284c79a..375d7ae3 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -5,6 +5,17 @@ import { queueWorkspaceQaDraft } from '../utils/workspaceQa'; import { queueReferenceChatDraft } from '../utils/referenceChatDraft'; import type { Reference } from '../components/references/types'; import { formatReferenceChatPrompt } from '../components/references/types'; +import { + OPTIMISTIC_SESSION_CREATED_EVENT, + type OptimisticSessionCreatedDetail, +} from '../constants/sessionEvents'; +import { normalizeProvider } from '../utils/providerPolicy'; +import { isTemporarySessionId } from '../utils/sessionScope'; +import { + hasTrackedTemporarySession, + isTrackedSessionActive, + upsertProjectSession, +} from './projectsSessionSync'; import type { AppSocketMessage, AppTab, @@ -400,6 +411,54 @@ export function useProjectsState({ }; }, []); + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const handleOptimisticSessionCreated = (event: Event) => { + const detail = (event as CustomEvent).detail; + if ( + !detail || + !detail.projectName || + !detail.sessionId || + !detail.provider + ) { + return; + } + + const sessionMode: SessionMode = isSessionMode(detail.mode) ? detail.mode : 'research'; + const createdAt = detail.createdAt || new Date().toISOString(); + const displayName = detail.displayName || detail.summary; + + setProjects((prevProjects) => prevProjects.map((project) => { + if (project.name !== detail.projectName) { + return project; + } + + return upsertProjectSession(project, { + projectName: detail.projectName, + provider: detail.provider, + sessionId: detail.sessionId, + mode: sessionMode, + displayName, + createdAt, + }); + })); + }; + + window.addEventListener( + OPTIMISTIC_SESSION_CREATED_EVENT, + handleOptimisticSessionCreated as EventListener, + ); + return () => { + window.removeEventListener( + OPTIMISTIC_SESSION_CREATED_EVENT, + handleOptimisticSessionCreated as EventListener, + ); + }; + }, []); + useEffect(() => { if (!latestMessage) { return; @@ -409,9 +468,29 @@ export function useProjectsState({ const rawMode = latestMessage.mode; const modeValue = typeof rawMode === 'string' ? rawMode : null; const sessionMode: SessionMode = isSessionMode(modeValue) ? modeValue : 'research'; - const createdProvider = latestMessage.provider as ProjectSession['__provider']; + const createdProvider = normalizeProvider( + latestMessage.provider as ProjectSession['__provider'], + ) as ProjectSession['__provider']; const createdDisplayName = latestMessage.displayName as string | undefined; const createdProjectName = latestMessage.projectName as string | undefined; + const fallbackProjectName = + selectedSession?.__projectName || + selectedProject?.name || + null; + const effectiveProjectName = createdProjectName || fallbackProjectName; + const selectedSessionProvider = normalizeProvider( + (selectedSession?.__provider || createdProvider || 'claude') as SessionProvider, + ) as SessionProvider; + const selectedSessionProjectName = + selectedSession?.__projectName || selectedProject?.name || null; + const temporarySessionIdToReplace = + isTemporarySessionId(selectedSession?.id) && + selectedSessionProvider === createdProvider && + selectedSessionProjectName && + effectiveProjectName && + selectedSessionProjectName === effectiveProjectName + ? selectedSession?.id || null + : null; setProjects((prevProjects) => prevProjects.map((project) => { const updateSessionList = ( @@ -450,52 +529,58 @@ export function useProjectsState({ nanoSessions: updateSessionList(project.nanoSessions, 'nano'), }; - if (createdProjectName && project.name === createdProjectName && createdProvider) { - const sessionArrayKey = createdProvider === 'claude' ? 'sessions' - : createdProvider === 'cursor' ? 'cursorSessions' - : createdProvider === 'codex' ? 'codexSessions' - : createdProvider === 'gemini' ? 'geminiSessions' - : createdProvider === 'openrouter' ? 'openrouterSessions' - : createdProvider === 'local' ? 'localSessions' - : createdProvider === 'nano' ? 'nanoSessions' - : null; - - if (sessionArrayKey) { - const arr = (nextProject[sessionArrayKey] as ProjectSession[] | undefined) || []; - const alreadyExists = arr.some((s) => s.id === latestMessage.sessionId); - if (!alreadyExists) { - const fallbackName = createdProvider === 'local' - ? 'Local GPU Session' - : createdProvider === 'nano' - ? 'Nano Claude Code Session' - : 'New Session'; - const newSession: ProjectSession = { - id: latestMessage.sessionId as string, - name: createdDisplayName || fallbackName, - summary: createdDisplayName || fallbackName, - mode: sessionMode, - __provider: createdProvider, - __projectName: project.name, - createdAt: new Date().toISOString(), - lastActivity: new Date().toISOString(), - }; - (nextProject as Record)[sessionArrayKey] = [newSession, ...arr]; - } - } + if (effectiveProjectName && project.name === effectiveProjectName && createdProvider) { + return upsertProjectSession(nextProject, { + projectName: effectiveProjectName, + provider: createdProvider, + sessionId: latestMessage.sessionId as string, + mode: sessionMode, + displayName: createdDisplayName, + createdAt: new Date().toISOString(), + temporarySessionId: temporarySessionIdToReplace, + }); } return nextProject; })); setSelectedSession((previous) => { - if (!previous || previous.id !== latestMessage.sessionId) { + if (!previous) { return previous; } - return { - ...previous, - mode: sessionMode, - }; + if (previous.id === latestMessage.sessionId) { + return { + ...previous, + mode: sessionMode, + __provider: previous.__provider || createdProvider, + __projectName: previous.__projectName || effectiveProjectName || undefined, + }; + } + + if ( + isTemporarySessionId(previous.id) && + temporarySessionIdToReplace && + previous.id === temporarySessionIdToReplace + ) { + return { + ...previous, + id: latestMessage.sessionId as string, + mode: sessionMode, + name: createdDisplayName || previous.name, + summary: createdDisplayName || previous.summary, + __provider: createdProvider, + __projectName: effectiveProjectName || previous.__projectName, + createdAt: previous.createdAt || new Date().toISOString(), + lastActivity: new Date().toISOString(), + }; + } + + if (previous.id !== latestMessage.sessionId) { + return previous; + } + + return previous; }); } @@ -545,7 +630,11 @@ export function useProjectsState({ const changedSessionId = filename.replace('.jsonl', ''); if (changedSessionId === selectedSession.id) { - const isSessionActive = activeSessions.has(selectedSession.id); + const isSessionActive = isTrackedSessionActive(activeSessions, { + sessionId: selectedSession.id, + provider: selectedSession.__provider, + projectName: selectedSession.__projectName || selectedProject.name, + }); if (!isSessionActive) { setExternalMessageUpdate((prev) => prev + 1); @@ -555,8 +644,15 @@ export function useProjectsState({ } const hasActiveSession = - (selectedSession && activeSessions.has(selectedSession.id)) || - (activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-'))); + Boolean( + selectedSession && + isTrackedSessionActive(activeSessions, { + sessionId: selectedSession.id, + provider: selectedSession.__provider, + projectName: selectedSession.__projectName || selectedProject?.name, + }), + ) || + hasTrackedTemporarySession(activeSessions); const updatedProjects = projectsMessage.projects; @@ -592,8 +688,19 @@ export function useProjectsState({ return; } + const normalizedSelectedProvider = normalizeProvider( + (selectedSession.__provider || 'claude') as SessionProvider, + ) as SessionProvider; + const selectedSessionProjectName = + selectedSession.__projectName || selectedProject.name; const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find( - (session) => session.id === selectedSession.id, + (session) => ( + session.id === selectedSession.id + && normalizeProvider( + (session.__provider || 'claude') as SessionProvider, + ) === normalizedSelectedProvider + && (session.__projectName || updatedSelectedProject.name) === selectedSessionProjectName + ), ); if (!updatedSelectedSession) { diff --git a/src/hooks/useSessionProtection.ts b/src/hooks/useSessionProtection.ts index 0c3d1bab..89bbc2f0 100644 --- a/src/hooks/useSessionProtection.ts +++ b/src/hooks/useSessionProtection.ts @@ -1,65 +1,193 @@ import { useCallback, useState } from 'react'; +import type { SessionProvider } from '../types/app'; +import { + buildSessionScopeKey, + isTemporarySessionId, + parseSessionScopeKey, + scopeKeyMatchesSessionId, +} from '../utils/sessionScope'; export function useSessionProtection() { const [activeSessions, setActiveSessions] = useState>(new Set()); const [processingSessions, setProcessingSessions] = useState>(new Set()); - const markSessionAsActive = useCallback((sessionId?: string | null) => { + const resolveTrackingKey = useCallback(( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => { if (!sessionId) { - return; + return ''; } - setActiveSessions((prev) => new Set([...prev, sessionId])); + const scopeKey = buildSessionScopeKey(projectName, provider, sessionId); + return scopeKey || sessionId; }, []); - const markSessionAsInactive = useCallback((sessionId?: string | null) => { - if (!sessionId) { + const markSessionAsActive = useCallback(( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => { + const trackingKey = resolveTrackingKey(sessionId, provider, projectName); + if (!trackingKey) { return; } setActiveSessions((prev) => { + if (prev.has(trackingKey)) { + return prev; + } const next = new Set(prev); - next.delete(sessionId); + next.add(trackingKey); return next; }); - }, []); + }, [resolveTrackingKey]); - const markSessionAsProcessing = useCallback((sessionId?: string | null) => { - if (!sessionId) { + const markSessionAsInactive = useCallback(( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => { + const trackingKey = resolveTrackingKey(sessionId, provider, projectName); + if (!trackingKey) { return; } - setProcessingSessions((prev) => new Set([...prev, sessionId])); - }, []); + setActiveSessions((prev) => { + const shouldDeleteTrackingKey = prev.has(trackingKey); + const shouldDeleteRawSessionId = Boolean(sessionId && prev.has(sessionId)); + if (!shouldDeleteTrackingKey && !shouldDeleteRawSessionId) { + return prev; + } - const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => { - if (!sessionId) { + const next = new Set(prev); + if (shouldDeleteTrackingKey) { + next.delete(trackingKey); + } + if (sessionId && shouldDeleteRawSessionId) { + next.delete(sessionId); + } + return next; + }); + }, [resolveTrackingKey]); + + const markSessionAsProcessing = useCallback(( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => { + const trackingKey = resolveTrackingKey(sessionId, provider, projectName); + if (!trackingKey) { return; } setProcessingSessions((prev) => { + if (prev.has(trackingKey)) { + return prev; + } const next = new Set(prev); - next.delete(sessionId); + next.add(trackingKey); return next; }); - }, []); + }, [resolveTrackingKey]); - const replaceTemporarySession = useCallback((realSessionId?: string | null) => { + const markSessionAsNotProcessing = useCallback(( + sessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + ) => { + const trackingKey = resolveTrackingKey(sessionId, provider, projectName); + if (!trackingKey) { + return; + } + + setProcessingSessions((prev) => { + const shouldDeleteTrackingKey = prev.has(trackingKey); + const shouldDeleteRawSessionId = Boolean(sessionId && prev.has(sessionId)); + if (!shouldDeleteTrackingKey && !shouldDeleteRawSessionId) { + return prev; + } + + const next = new Set(prev); + if (shouldDeleteTrackingKey) { + next.delete(trackingKey); + } + if (sessionId && shouldDeleteRawSessionId) { + next.delete(sessionId); + } + return next; + }); + }, [resolveTrackingKey]); + + const replaceTemporarySession = useCallback(( + realSessionId?: string | null, + provider?: SessionProvider | null, + projectName?: string | null, + previousSessionId?: string | null, + ) => { if (!realSessionId) { return; } + const realTrackingKey = resolveTrackingKey(realSessionId, provider, projectName); + if (!realTrackingKey) { + return; + } + + const shouldReplaceTemporary = (trackingKey: string) => { + if (!trackingKey) { + return false; + } + + const parsed = parseSessionScopeKey(trackingKey); + const parsedSessionId = parsed?.sessionId || trackingKey; + if (!isTemporarySessionId(parsedSessionId)) { + return false; + } + + if (previousSessionId) { + return scopeKeyMatchesSessionId(trackingKey, previousSessionId); + } + + if (projectName && parsed?.projectName && parsed.projectName !== projectName) { + return false; + } + + if (provider && parsed?.provider && parsed.provider !== provider) { + return false; + } + + return true; + }; + setActiveSessions((prev) => { const next = new Set(); for (const sessionId of prev) { - if (!sessionId.startsWith('new-session-')) { + if (!shouldReplaceTemporary(sessionId)) { next.add(sessionId); } } - next.add(realSessionId); + next.add(realTrackingKey); return next; }); - }, []); + + setProcessingSessions((prev) => { + const next = new Set(); + let hadTemporarySession = false; + for (const sessionId of prev) { + if (shouldReplaceTemporary(sessionId)) { + hadTemporarySession = true; + continue; + } + next.add(sessionId); + } + if (hadTemporarySession) { + next.add(realTrackingKey); + } + return next; + }); + }, [resolveTrackingKey]); return { activeSessions,