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) {