diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index f380c64a..fd612e55 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -17,6 +17,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.noMatching': 'No matching sessions', 'sidebar.sessionListFailed': 'Session list failed', 'common.retry': 'Retry', + 'common.loading': 'Loading...', 'common.cancel': 'Cancel', 'common.delete': 'Delete', 'common.rename': 'Rename', @@ -201,4 +202,13 @@ describe('Sidebar', () => { expect(screen.getByTestId('sidebar-session-list-section')).toHaveClass('flex', 'flex-1', 'min-h-0', 'flex-col') }) + + it('shows a loading state instead of an empty session list while initial fetch is pending', () => { + useSessionStore.setState({ isLoading: true, sessions: [] }) + + render() + + expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.queryByText('No sessions')).not.toBeInTheDocument() + }) }) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index c0add799..c8433899 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -18,6 +18,7 @@ const TIME_GROUP_ORDER: TimeGroup[] = ['today', 'yesterday', 'last7days', 'last3 export function Sidebar() { const sessions = useSessionStore((s) => s.sessions) const selectedProjects = useSessionStore((s) => s.selectedProjects) + const isLoading = useSessionStore((s) => s.isLoading) const error = useSessionStore((s) => s.error) const fetchSessions = useSessionStore((s) => s.fetchSessions) const deleteSession = useSessionStore((s) => s.deleteSession) @@ -63,6 +64,7 @@ export function Sidebar() { }, [sessions, selectedProjects, searchQuery]) const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions]) + const showInitialLoading = isLoading && sessions.length === 0 const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => { e.preventDefault() @@ -245,7 +247,11 @@ export function Sidebar() { )} - {filteredSessions.length === 0 && ( + {showInitialLoading ? ( +
+ {t('common.loading')} +
+ ) : filteredSessions.length === 0 && (
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 1e92a828..4b52bbdc 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -411,7 +411,7 @@ const TabItem = forwardRef { expect(svc.getActiveSessions()).toEqual([]) }) + it('should block startup after a session is deleted during prewarm', async () => { + const svc = new ConversationService() + const sid = crypto.randomUUID() + + svc.markSessionDeleted(sid) + + try { + await svc.startSession(sid, process.cwd(), 'ws://127.0.0.1:1/sdk/test') + throw new Error('expected startSession to reject') + } catch (error) { + expect(error).toBeInstanceOf(ConversationStartupError) + expect((error as ConversationStartupError).code).toBe('SESSION_DELETED') + } + }) + it('should return false when sending message to non-existent session', async () => { const svc = new ConversationService() const result = await svc.sendMessage('no-such-session', 'hello') diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 7b66c2af..e3e208d6 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -374,6 +374,34 @@ describe('SessionService', () => { expect(page2.sessions).toHaveLength(1) }) + it('should only parse the requested page when listing many sessions', async () => { + for (let i = 0; i < 12; i++) { + const id = `1000000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee` + const filePath = await writeSessionFile('-tmp-many-sessions', id, [ + makeSnapshotEntry(), + makeUserEntry(`Message ${i}`), + ]) + const mtime = new Date(Date.now() - i * 1000) + await fs.utimes(filePath, mtime, mtime) + } + + const serviceWithSpy = service as unknown as { + readJsonlFile: (...args: unknown[]) => Promise + } + const originalReadJsonlFile = serviceWithSpy.readJsonlFile.bind(service) + let readCount = 0 + serviceWithSpy.readJsonlFile = async (...args) => { + readCount += 1 + return originalReadJsonlFile(...args) + } + + const result = await service.listSessions({ limit: 3, offset: 0 }) + + expect(result.total).toBe(12) + expect(result.sessions).toHaveLength(3) + expect(readCount).toBe(3) + }) + it('should filter sessions by project', async () => { const id1 = 'aaaaaaaa-1111-cccc-dddd-eeeeeeeeeeee' const id2 = 'aaaaaaaa-2222-cccc-dddd-eeeeeeeeeeee' diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index f01d2670..30893fb9 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import * as fs from 'fs/promises' import * as os from 'os' import * as path from 'path' -import { generateTitle } from '../services/titleService.js' +import { generateTitle, saveAiTitle } from '../services/titleService.js' +import { sessionService } from '../services/sessionService.js' describe('titleService', () => { let tmpDir: string @@ -68,6 +69,21 @@ describe('titleService', () => { server.stop(true) } }) + + test('does not persist automatic titles over a user custom title', async () => { + const { sessionId } = await sessionService.createSession(os.tmpdir()) + await sessionService.renameSession(sessionId, 'My fixed name') + + await expect(saveAiTitle(sessionId, 'Automatic topic')).resolves.toBe(false) + + const detail = await sessionService.getSession(sessionId) + expect(detail?.title).toBe('My fixed name') + + const found = await sessionService.findSessionFile(sessionId) + expect(found).not.toBeNull() + const content = await fs.readFile(found!.filePath, 'utf-8') + expect(content).not.toContain('"type":"ai-title"') + }) }) function restoreEnv(key: string, value: string | undefined) { diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 94f3d6c8..324a9cbd 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -308,6 +308,7 @@ function isSessionNotFoundError(error: unknown): error is Error { } async function deleteSession(sessionId: string): Promise { + conversationService.markSessionDeleted(sessionId) await sessionService.deleteSession(sessionId) return Response.json({ ok: true }) } diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 4c55d4e8..88dff761 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -61,7 +61,8 @@ export class ConversationStartupError extends Error { | 'CLI_AUTH_REQUIRED' | 'CLI_SESSION_CONFLICT' | 'CLI_START_FAILED' - | 'CLI_SPAWN_FAILED', + | 'CLI_SPAWN_FAILED' + | 'SESSION_DELETED', readonly retryable = false, ) { super(message) @@ -71,6 +72,7 @@ export class ConversationStartupError extends Error { export class ConversationService { private sessions = new Map() + private deletedSessions = new Set() private providerService = new ProviderService() private buildSessionCliArgs( @@ -106,6 +108,12 @@ export class ConversationService { sdkUrl: string, options?: SessionStartOptions, ): Promise { + if (this.deletedSessions.has(sessionId)) { + throw new ConversationStartupError( + `Session was deleted before startup completed: ${sessionId}`, + 'SESSION_DELETED', + ) + } if (this.sessions.has(sessionId)) return const launchInfo = await sessionService.getSessionLaunchInfo(sessionId) @@ -113,6 +121,13 @@ export class ConversationService { const shouldReplacePlaceholder = !!launchInfo && launchInfo.transcriptMessageCount === 0 + if (this.deletedSessions.has(sessionId)) { + throw new ConversationStartupError( + `Session was deleted before startup completed: ${sessionId}`, + 'SESSION_DELETED', + ) + } + if (shouldReplacePlaceholder) { await sessionService.deleteSessionFile(sessionId) } @@ -479,6 +494,11 @@ export class ConversationService { } } + markSessionDeleted(sessionId: string): void { + this.deletedSessions.add(sessionId) + this.stopSession(sessionId) + } + getActiveSessions(): string[] { return Array.from(this.sessions.keys()) } diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index aa5ee911..6abab2cd 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -991,13 +991,27 @@ export class SessionService { offset?: number }): Promise<{ sessions: SessionListItem[]; total: number }> { const sessionFiles = await this.discoverSessionFiles(options?.project) + const filesWithStats = (await Promise.all(sessionFiles.map(async (sessionFile) => { + try { + return { + ...sessionFile, + stat: await fs.stat(sessionFile.filePath), + } + } catch { + return null + } + }))).filter((item): item is NonNullable => item !== null) + + filesWithStats.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime()) + + const total = filesWithStats.length + const offset = options?.offset ?? 0 + const limit = options?.limit ?? 50 + const paginatedFiles = filesWithStats.slice(offset, offset + limit) // Build session list items with metadata from file stats & first entries - const items: SessionListItem[] = [] - - for (const { filePath, projectDir, sessionId } of sessionFiles) { + const items = (await Promise.all(paginatedFiles.map(async ({ filePath, projectDir, sessionId, stat }) => { try { - const stat = await fs.stat(filePath) const entries = await this.readJsonlFile(filePath) const workDir = this.resolveWorkDirFromEntries(entries, projectDir) const workDirExists = await this.pathExists(workDir) @@ -1018,7 +1032,7 @@ export class SessionService { } } - items.push({ + return { id: sessionId, title, createdAt, @@ -1027,21 +1041,14 @@ export class SessionService { projectPath: projectDir, workDir, workDirExists, - }) + } } catch { // Skip unreadable files + return null } - } + }))).filter((item): item is SessionListItem => item !== null) - // Sort by modifiedAt descending (most recent first) - items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()) - - const total = items.length - const offset = options?.offset ?? 0 - const limit = options?.limit ?? 50 - const paginated = items.slice(offset, offset + limit) - - return { sessions: paginated, total } + return { sessions: items, total } } /** @@ -1212,6 +1219,20 @@ export class SessionService { }) } + async getCustomTitle(sessionId: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) return null + + const entries = await this.readJsonlFile(found.filePath) + let customTitle: string | null = null + for (const entry of entries) { + if (entry.type === 'custom-title' && typeof entry.customTitle === 'string' && entry.customTitle.trim()) { + customTitle = entry.customTitle + } + } + return customTitle + } + /** * Get the actual working directory for a session. * First checks for stored session-meta entry, then falls back to desanitizePath. diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index fd319ae4..c4cce896 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -123,7 +123,13 @@ async function shouldDisableThinkingForTitle(presetId: string): Promise /** * Persist an AI-generated title to the session's JSONL file. + * Returns false when a user custom title exists, because custom titles are + * intentional and must not be replaced by automatic title refreshes. */ -export async function saveAiTitle(sessionId: string, title: string): Promise { +export async function saveAiTitle(sessionId: string, title: string): Promise { + if (await sessionService.getCustomTitle(sessionId)) { + return false + } await sessionService.appendAiTitle(sessionId, title) + return true } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 5656e809..6c1f623e 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -278,7 +278,12 @@ async function handleUserMessage( // Track user message for title generation let titleState = sessionTitleState.get(sessionId) if (!titleState) { - titleState = { userMessageCount: 0, hasCustomTitle: false, firstUserMessage: '', allUserMessages: [] } + titleState = { + userMessageCount: 0, + hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)), + firstUserMessage: '', + allUserMessages: [], + } sessionTitleState.set(sessionId, titleState) } titleState.userMessageCount++ @@ -606,7 +611,11 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s if (count === 1) { const placeholder = deriveTitle(text) if (placeholder) { - await saveAiTitle(sessionId, placeholder) + const saved = await saveAiTitle(sessionId, placeholder) + if (!saved) { + state.hasCustomTitle = true + return + } sendMessage(ws, { type: 'session_title_updated', sessionId, title: placeholder }) } } @@ -614,7 +623,11 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s // Stage 2: AI-generated title const aiTitle = await generateTitle(text, runtimeProviderId) if (aiTitle) { - await saveAiTitle(sessionId, aiTitle) + const saved = await saveAiTitle(sessionId, aiTitle) + if (!saved) { + state.hasCustomTitle = true + return + } sendMessage(ws, { type: 'session_title_updated', sessionId, title: aiTitle }) } } catch (err) {