diff --git a/desktop/src/lib/sessionTitle.ts b/desktop/src/lib/sessionTitle.ts new file mode 100644 index 00000000..5e4280cb --- /dev/null +++ b/desktop/src/lib/sessionTitle.ts @@ -0,0 +1,21 @@ +const TITLE_MAX_LEN = 50 + +const PLACEHOLDER_TITLES = new Set([ + '', + 'New Session', + 'Untitled Session', +]) + +export function deriveSessionTitle(raw: string): string | null { + const clean = raw.replace(/<[^>]+>[^<]*<\/[^>]+>/g, '').trim() + const firstSentence = /^(.*?[.!?\u3002\uff01\uff1f])\s/.exec(clean)?.[1] ?? clean + const flat = firstSentence.replace(/\s+/g, ' ').trim() + if (!flat) return null + return flat.length > TITLE_MAX_LEN + ? `${flat.slice(0, TITLE_MAX_LEN - 1)}\u2026` + : flat +} + +export function isPlaceholderSessionTitle(title: string | null | undefined): boolean { + return PLACEHOLDER_TITLES.has((title ?? '').trim()) +} diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 63e70b06..2c515703 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -180,7 +180,7 @@ describe('EmptySession', () => { expect(useTabStore.getState().activeTabId).toBe('draft-session') expect(useTabStore.getState().tabs).toEqual([ - { sessionId: 'draft-session', title: 'New Session', type: 'session', status: 'idle' }, + { sessionId: 'draft-session', title: 'draft question', type: 'session', status: 'idle' }, ]) expect(useSessionStore.getState().sessions[0]).toMatchObject({ id: 'draft-session', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index f20d9711..ce232140 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -16,6 +16,10 @@ const { resetCompletedTasksMock, refreshTasksMock, notifyDesktopMock, + updateTabTitleMock, + updateTabStatusMock, + updateSessionTitleMock, + sessionStoreSnapshot, cliTaskStoreSnapshot, } = vi.hoisted(() => ({ sendMock: vi.fn(), @@ -31,6 +35,21 @@ const { resetCompletedTasksMock: vi.fn(async () => {}), refreshTasksMock: vi.fn(), notifyDesktopMock: vi.fn(), + updateTabTitleMock: vi.fn(), + updateTabStatusMock: vi.fn(), + updateSessionTitleMock: vi.fn(), + sessionStoreSnapshot: { + sessions: [] as Array<{ + id: string + title: string + createdAt: string + modifiedAt: string + messageCount: number + projectPath: string + workDir: string | null + workDirExists: boolean + }>, + }, cliTaskStoreSnapshot: { tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>, sessionId: null as string | null, @@ -73,8 +92,8 @@ vi.mock('./teamStore', () => ({ vi.mock('./tabStore', () => ({ useTabStore: { getState: () => ({ - updateTabStatus: vi.fn(), - updateTabTitle: vi.fn(), + updateTabStatus: updateTabStatusMock, + updateTabTitle: updateTabTitleMock, }), }, })) @@ -82,7 +101,8 @@ vi.mock('./tabStore', () => ({ vi.mock('./sessionStore', () => ({ useSessionStore: { getState: () => ({ - updateSessionTitle: vi.fn(), + sessions: sessionStoreSnapshot.sessions, + updateSessionTitle: updateSessionTitleMock, }), }, })) @@ -120,6 +140,10 @@ describe('chatStore history mapping', () => { resetCompletedTasksMock.mockReset() refreshTasksMock.mockReset() notifyDesktopMock.mockReset() + updateTabTitleMock.mockReset() + updateTabStatusMock.mockReset() + updateSessionTitleMock.mockReset() + sessionStoreSnapshot.sessions = [] cliTaskStoreSnapshot.tasks = [] cliTaskStoreSnapshot.sessionId = null useSessionRuntimeStore.setState({ selections: {} }) @@ -1407,4 +1431,27 @@ describe('chatStore history mapping', () => { expect(fetchSessionTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID) }) + + it('optimistically titles a new placeholder session from the first user message', () => { + sessionStoreSnapshot.sessions = [{ + id: TEST_SESSION_ID, + title: 'New Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 0, + projectPath: '', + workDir: '/workspace/project', + workDirExists: true, + }] + + useChatStore.getState().sendMessage(TEST_SESSION_ID, '开始优化UI') + + expect(updateSessionTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI') + expect(updateTabTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI') + expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { + type: 'user_message', + content: '开始优化UI', + attachments: undefined, + }) + }) }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 21c432f5..00020f54 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -8,6 +8,7 @@ import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' import { notifyDesktop } from '../lib/desktopNotifications' +import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle' import { AGENT_LIFECYCLE_TYPES } from '../types/team' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' @@ -325,6 +326,10 @@ export const useChatStore = create((set, get) => ({ void taskStore.resetCompletedTasks() } + if (!isMemberSession) { + updateOptimisticSessionTitle(sessionId, userFacingContent) + } + set((s) => { const session = s.sessions[sessionId] ?? createDefaultSessionState() if (flushTimer) { @@ -924,6 +929,17 @@ export const useChatStore = create((set, get) => ({ }, })) +function updateOptimisticSessionTitle(sessionId: string, content: string): void { + const title = deriveSessionTitle(content) + if (!title) return + + const session = useSessionStore.getState().sessions.find((item) => item.id === sessionId) + if (!session || session.messageCount > 0 || !isPlaceholderSessionTitle(session.title)) return + + useSessionStore.getState().updateSessionTitle(sessionId, title) + useTabStore.getState().updateTabTitle(sessionId, title) +} + // ─── History mapping helpers (unchanged from original) ───────── type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown } diff --git a/desktop/src/stores/sessionStore.test.ts b/desktop/src/stores/sessionStore.test.ts index 606ceca6..6c91cb85 100644 --- a/desktop/src/stores/sessionStore.test.ts +++ b/desktop/src/stores/sessionStore.test.ts @@ -22,6 +22,14 @@ function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + describe('sessionStore', () => { beforeEach(() => { createMock.mockReset() @@ -64,6 +72,45 @@ describe('sessionStore', () => { expect(listMock).toHaveBeenCalledOnce() }) + it('keeps an optimistic local title when a background refresh still returns a placeholder', async () => { + const refresh = createDeferred<{ + sessions: Array<{ + id: string + title: string + createdAt: string + modifiedAt: string + messageCount: number + projectPath: string + workDir: string | null + workDirExists: boolean + }> + total: number + }>() + createMock.mockResolvedValue({ sessionId: 'session-title-1', workDir: '/workspace/project' }) + listMock.mockReturnValue(refresh.promise) + + await useSessionStore.getState().createSession('/workspace/project') + useSessionStore.getState().updateSessionTitle('session-title-1', '开始优化UI') + + refresh.resolve({ + sessions: [{ + id: 'session-title-1', + title: 'Untitled Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:01.000Z', + messageCount: 0, + projectPath: '', + workDir: '/workspace/project', + workDirExists: true, + }], + total: 1, + }) + await refresh.promise + await delay(0) + + expect(useSessionStore.getState().sessions[0]?.title).toBe('开始优化UI') + }) + it('forwards direct branch switch repository options when creating a session', async () => { createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' }) listMock.mockResolvedValue({ sessions: [], total: 0 }) diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index 5d338866..cf11b11c 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions' import { useSessionRuntimeStore } from './sessionRuntimeStore' import type { SessionListItem } from '../types/session' +import { isPlaceholderSessionTitle } from '../lib/sessionTitle' type CreateSessionOptions = { repository?: CreateSessionRepositoryOptions @@ -36,17 +37,22 @@ export const useSessionStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const { sessions: raw } = await sessionsApi.list({ project, limit: 100 }) - // Deduplicate by session ID — keep the most recently modified entry - const byId = new Map() - for (const s of raw) { - const existing = byId.get(s.id) - if (!existing || new Date(s.modifiedAt) > new Date(existing.modifiedAt)) { - byId.set(s.id, s) + set((state) => { + const currentById = new Map(state.sessions.map((session) => [session.id, session])) + // Deduplicate by session ID - keep the most recently modified entry. + const byId = new Map() + for (const s of raw) { + const current = currentById.get(s.id) + const candidate = preserveLocalTitle(current, s) + const existing = byId.get(s.id) + if (!existing || new Date(candidate.modifiedAt) > new Date(existing.modifiedAt)) { + byId.set(s.id, candidate) + } } - } - const sessions = [...byId.values()] - const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort() - set({ sessions, availableProjects, isLoading: false }) + const sessions = [...byId.values()] + const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort() + return { sessions, availableProjects, isLoading: false } + }) } catch (err) { set({ error: (err as Error).message, isLoading: false }) } @@ -109,3 +115,14 @@ export const useSessionStore = create((set, get) => ({ setActiveSession: (id) => set({ activeSessionId: id }), setSelectedProjects: (projects) => set({ selectedProjects: projects }), })) + +function preserveLocalTitle( + current: SessionListItem | undefined, + incoming: SessionListItem, +): SessionListItem { + if (!current) return incoming + if (isPlaceholderSessionTitle(incoming.title) && !isPlaceholderSessionTitle(current.title)) { + return { ...incoming, title: current.title } + } + return incoming +} diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 5fb1fdca..edd3cdd3 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -817,6 +817,47 @@ describe('WebSocket Chat Integration', () => { expect(statusMsgs[0].state).toBe('thinking') }) + it('emits the derived session title before the first response completes', async () => { + const sessionId = `title-fast-${crypto.randomUUID()}` + const messages: any[] = [] + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Timed out waiting for derived session title')) + }, 5000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + messages.push(msg) + if (msg.type === 'connected') { + ws.send(JSON.stringify({ + type: 'user_message', + content: '开始优化UI', + })) + return + } + if (msg.type === 'message_complete') { + clearTimeout(timeout) + ws.close() + resolve() + } + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for title session ${sessionId}`)) + } + }) + + const titleIndex = messages.findIndex((msg) => msg.type === 'session_title_updated') + const completionIndex = messages.findIndex((msg) => msg.type === 'message_complete') + expect(titleIndex).toBeGreaterThan(-1) + expect(completionIndex).toBeGreaterThan(-1) + expect(messages[titleIndex].title).toBe('开始优化UI') + expect(titleIndex).toBeLessThan(completionIndex) + }) + it('should start desktop sessions with disabled thinking when configured', async () => { const sessionId = `chat-thinking-disabled-${crypto.randomUUID()}` const originalStartSession = conversationService.startSession.bind(conversationService) diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index 30893fb9..cca7c937 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -2,7 +2,7 @@ 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, saveAiTitle } from '../services/titleService.js' +import { generateTitle, parseGeneratedTitleText, saveAiTitle } from '../services/titleService.js' import { sessionService } from '../services/sessionService.js' describe('titleService', () => { @@ -70,6 +70,20 @@ describe('titleService', () => { } }) + test('parses JSON title responses wrapped in markdown fences', () => { + expect(parseGeneratedTitleText('```json\n{"title":"Write bash script"}\n```')) + .toBe('Write bash script') + }) + + test('parses escaped JSON title responses', () => { + expect(parseGeneratedTitleText('```json\n{\\"title\\":\\"Write bash script\\"}\n```')) + .toBe('Write bash script') + }) + + test('rejects incomplete JSON title fragments instead of using them as titles', () => { + expect(parseGeneratedTitleText('```json\n{\\"title\\":')).toBeNull() + }) + 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') diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index c4cce896..b91a9c6a 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -99,20 +99,78 @@ export async function generateTitle( const text = body.content?.find((b) => b.type === 'text')?.text if (!text) return null - // Parse JSON response - const match = text.match(/\{[^}]*"title"\s*:\s*"([^"]+)"[^}]*\}/) - if (match?.[1]) return match[1].trim() - - // Fallback: if model returned plain text instead of JSON - const plain = text.trim() - if (plain.length > 0 && plain.length <= 60) return plain - - return null + return parseGeneratedTitleText(text) } catch { return null } } +export function parseGeneratedTitleText(text: string): string | null { + const trimmed = text.trim() + if (!trimmed) return null + + const parsed = parseTitleFromStructuredText(trimmed) + if (parsed) return normalizeTitle(parsed) + + if (looksLikeStructuredTitleFragment(trimmed)) return null + + return normalizeTitle(trimmed) +} + +function parseTitleFromStructuredText(text: string): string | null { + const candidates = new Set([text]) + const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim() + if (fenced) candidates.add(fenced) + + const firstBrace = text.indexOf('{') + const lastBrace = text.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace > firstBrace) { + candidates.add(text.slice(firstBrace, lastBrace + 1)) + } + + for (const candidate of [...candidates]) { + const unescaped = candidate.replace(/\\"/g, '"').replace(/\\n/g, '\n') + if (unescaped !== candidate) candidates.add(unescaped) + } + + for (const candidate of candidates) { + const title = parseTitleJson(candidate) + if (title) return title + } + + return null +} + +function parseTitleJson(candidate: string): string | null { + try { + const parsed = JSON.parse(candidate) + if (typeof parsed === 'string') { + return parseTitleFromStructuredText(parsed) + } + if (parsed && typeof parsed === 'object' && typeof (parsed as { title?: unknown }).title === 'string') { + return (parsed as { title: string }).title + } + } catch { + return null + } + return null +} + +function normalizeTitle(title: string): string | null { + const clean = title.replace(/\s+/g, ' ').trim() + if (!clean || clean.length > 60 || looksLikeStructuredTitleFragment(clean)) return null + return clean +} + +function looksLikeStructuredTitleFragment(text: string): boolean { + return ( + text.includes('```') || + text.includes('{') || + text.includes('}') || + /\\?"title\\?"\s*:/.test(text) + ) +} + async function shouldDisableThinkingForTitle(presetId: string): Promise { const settings = await new SettingsService().getUserSettings() if (settings.alwaysThinkingEnabled !== false) return false diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 8fe0b198..a49bf367 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -53,6 +53,7 @@ const sessionTitleState = new Map }>() const runtimeOverrides = new Map(), + } + sessionTitleState.set(sessionId, titleState) + } + titleState.userMessageCount++ + titleState.allUserMessages.push(message.content) + if (titleState.userMessageCount === 1) { + titleState.firstUserMessage = message.content + } + triggerTitleGeneration(ws, sessionId) + // 启动 CLI 子进程(如果还没有) try { await ensureCliSessionStarted(ws, sessionId, 'user_message') @@ -290,23 +310,6 @@ async function handleUserMessage( return } - // Track user message for title generation - let titleState = sessionTitleState.get(sessionId) - if (!titleState) { - titleState = { - userMessageCount: 0, - hasCustomTitle: !!(await sessionService.getCustomTitle(sessionId)), - firstUserMessage: '', - allUserMessages: [], - } - sessionTitleState.set(sessionId, titleState) - } - titleState.userMessageCount++ - titleState.allUserMessages.push(message.content) - if (titleState.userMessageCount === 1) { - titleState.firstUserMessage = message.content - } - // Register the callback before sending the turn so startup errors are not lost. // Keep output muted until the current user turn is enqueued to avoid forwarding // any pre-turn SDK chatter as fresh chat history. @@ -627,6 +630,8 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s // Generate on count 1 (first response) and count 3 (with more context) if (count !== 1 && count !== 3) return + if (state.startedGenerationCounts.has(count)) return + state.startedGenerationCounts.add(count) const text = count === 1 ? state.firstUserMessage