cc-haha/desktop/src/stores/sessionStore.ts
程序员阿江(Relakkes) 2dd8ee4a33 fix: keep open session tab titles in sync
Open session tabs can outlive the title value they were created with, while the sidebar and session detail view read from the refreshed session list. Refresh existing tab titles when a session tab is reopened and after session list refreshes so the top tab bar cannot keep a stale generated title.

Constraint: Sidebar filtering and session refresh should not leave already-open tabs with stale labels.
Rejected: Only rely on WebSocket title update events | restored tabs and already-open tabs can miss later session-list corrections.
Confidence: high
Scope-risk: narrow
Directive: Keep session tab titles derived from current session list data when available.
Tested: cd desktop && bun run test -- --run src/stores/tabStore.test.ts src/stores/sessionStore.test.ts
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test -- --run
Tested: agent-browser smoke on http://127.0.0.1:1420/ confirmed top tabs match sidebar and session detail titles.
2026-05-07 16:38:27 +08:00

145 lines
4.8 KiB
TypeScript

import { create } from 'zustand'
import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useTabStore } from './tabStore'
import type { SessionListItem } from '../types/session'
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
type CreateSessionOptions = {
repository?: CreateSessionRepositoryOptions
}
type SessionStore = {
sessions: SessionListItem[]
activeSessionId: string | null
isLoading: boolean
error: string | null
selectedProjects: string[]
availableProjects: string[]
fetchSessions: (project?: string) => Promise<void>
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
deleteSession: (id: string) => Promise<void>
renameSession: (id: string, title: string) => Promise<void>
updateSessionTitle: (id: string, title: string) => void
setActiveSession: (id: string | null) => void
setSelectedProjects: (projects: string[]) => void
}
export const useSessionStore = create<SessionStore>((set, get) => ({
sessions: [],
activeSessionId: null,
isLoading: false,
error: null,
selectedProjects: [],
availableProjects: [],
fetchSessions: async (project?: string) => {
set({ isLoading: true, error: null })
try {
const { sessions: raw } = await sessionsApi.list({ project, limit: 100 })
let syncedSessions: SessionListItem[] = []
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<string, SessionListItem>()
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()]
syncedSessions = sessions
const availableProjects = [...new Set(sessions.map((s) => s.projectPath).filter(Boolean))].sort()
return { sessions, availableProjects, isLoading: false }
})
syncOpenSessionTabTitles(syncedSessions)
} catch (err) {
set({ error: (err as Error).message, isLoading: false })
}
},
createSession: async (workDir?: string, options?: CreateSessionOptions) => {
const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({
...(workDir ? { workDir } : {}),
...(options?.repository ? { repository: options.repository } : {}),
})
const now = new Date().toISOString()
const optimisticSession: SessionListItem = {
id,
title: 'New Session',
createdAt: now,
modifiedAt: now,
messageCount: 0,
projectPath: '',
workDir: resolvedWorkDir ?? workDir ?? null,
workDirExists: true,
}
set((state) => ({
sessions: state.sessions.some((session) => session.id === id)
? state.sessions
: [optimisticSession, ...state.sessions],
activeSessionId: id,
}))
void get().fetchSessions()
return id
},
deleteSession: async (id: string) => {
await sessionsApi.delete(id)
useSessionRuntimeStore.getState().clearSelection(id)
set((s) => ({
sessions: s.sessions.filter((session) => session.id !== id),
activeSessionId: s.activeSessionId === id ? null : s.activeSessionId,
}))
},
renameSession: async (id: string, title: string) => {
await sessionsApi.rename(id, title)
set((s) => ({
sessions: s.sessions.map((session) =>
session.id === id ? { ...session, title } : session,
),
}))
},
updateSessionTitle: (id, title) => {
set((s) => ({
sessions: s.sessions.map((session) =>
session.id === id ? { ...session, title } : session,
),
}))
},
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
}
function syncOpenSessionTabTitles(sessions: SessionListItem[]): void {
const titleById = new Map(sessions.map((session) => [session.id, session.title]))
const { tabs, updateTabTitle } = useTabStore.getState()
for (const tab of tabs) {
if (tab.type !== 'session') continue
const title = titleById.get(tab.sessionId)
if (title && title !== tab.title) {
updateTabTitle(tab.sessionId, title)
}
}
}