Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 128 additions & 2 deletions src/components/chat/utils/chatStorage.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
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';
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) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
175 changes: 175 additions & 0 deletions src/hooks/__tests__/projectsSessionSync.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string>([
'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);
});
});
Loading