{/* Session info header */}
diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx
index 3c49d504..8e1df3e1 100644
--- a/desktop/src/pages/EmptySession.tsx
+++ b/desktop/src/pages/EmptySession.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from '../i18n'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useUIStore } from '../stores/uiStore'
+import { useTabStore } from '../stores/tabStore'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector'
@@ -122,6 +123,7 @@ export function EmptySession() {
try {
const sessionId = await createSession(workDir || undefined)
setActiveView('code')
+ useTabStore.getState().openTab(sessionId, 'New Session')
connectToSession(sessionId)
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type,
@@ -129,7 +131,7 @@ export function EmptySession() {
data: attachment.data,
mimeType: attachment.mimeType,
}))
- sendMessage(text, attachmentPayload)
+ sendMessage(sessionId, text, attachmentPayload)
setInput('')
setAttachments([])
} catch (error) {
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index f7fc8a3b..b2320114 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -32,8 +32,39 @@ vi.mock('./teamStore', () => ({
},
}))
+vi.mock('./tabStore', () => ({
+ useTabStore: {
+ getState: () => ({
+ updateTabStatus: vi.fn(),
+ updateTabTitle: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('./sessionStore', () => ({
+ useSessionStore: {
+ getState: () => ({
+ updateSessionTitle: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('./cliTaskStore', () => ({
+ useCLITaskStore: {
+ getState: () => ({
+ fetchSessionTasks: vi.fn(),
+ tasks: [],
+ clearTasks: vi.fn(),
+ setTasksFromTodos: vi.fn(),
+ markCompletedAndDismissed: vi.fn(),
+ refreshTasks: vi.fn(),
+ }),
+ },
+}))
+
import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore'
+const TEST_SESSION_ID = 'test-session-1'
const initialState = useChatStore.getState()
describe('chatStore history mapping', () => {
@@ -41,19 +72,7 @@ describe('chatStore history mapping', () => {
sendMock.mockReset()
useChatStore.setState({
...initialState,
- messages: [],
- chatState: 'idle',
- connectionState: 'disconnected',
- streamingText: '',
- streamingToolInput: '',
- activeToolUseId: null,
- activeToolName: null,
- activeThinkingId: null,
- pendingPermission: null,
- tokenUsage: { input_tokens: 0, output_tokens: 0 },
- elapsedSeconds: 0,
- connectedSessionId: null,
- slashCommands: [],
+ sessions: {},
})
})
@@ -95,7 +114,29 @@ describe('chatStore history mapping', () => {
})
it('keeps parent tool linkage for live tool events', () => {
- useChatStore.getState().handleServerMessage({
+ // Initialize the session first
+ useChatStore.setState({
+ sessions: {
+ [TEST_SESSION_ID]: {
+ messages: [],
+ chatState: 'idle',
+ connectionState: 'connected',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: null,
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
+ elapsedSeconds: 0,
+ statusVerb: '',
+ slashCommands: [],
+ elapsedTimer: null,
+ },
+ },
+ })
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_use_complete',
toolName: 'Read',
toolUseId: 'tool-1',
@@ -103,7 +144,7 @@ describe('chatStore history mapping', () => {
parentToolUseId: 'agent-1',
})
- useChatStore.getState().handleServerMessage({
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_result',
toolUseId: 'tool-1',
content: 'ok',
@@ -111,7 +152,7 @@ describe('chatStore history mapping', () => {
parentToolUseId: 'agent-1',
})
- expect(useChatStore.getState().messages).toMatchObject([
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'tool_use',
toolUseId: 'tool-1',
@@ -126,13 +167,32 @@ describe('chatStore history mapping', () => {
})
it('sends permission mode updates to the active session only', () => {
- useChatStore.getState().setSessionPermissionMode('acceptEdits')
+ useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits')
expect(sendMock).not.toHaveBeenCalled()
- useChatStore.setState({ connectedSessionId: 'session-1' })
- useChatStore.getState().setSessionPermissionMode('acceptEdits')
+ useChatStore.setState({
+ sessions: {
+ 'session-1': {
+ messages: [],
+ chatState: 'idle',
+ connectionState: 'connected',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: null,
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
+ elapsedSeconds: 0,
+ statusVerb: '',
+ slashCommands: [],
+ elapsedTimer: null,
+ },
+ },
+ })
+ useChatStore.getState().setSessionPermissionMode('session-1', 'acceptEdits')
- expect(sendMock).toHaveBeenCalledWith({
+ expect(sendMock).toHaveBeenCalledWith('session-1', {
type: 'set_permission_mode',
mode: 'acceptEdits',
})
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index ba9ef9de..eb7a406f 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -4,6 +4,7 @@ import { sessionsApi } from '../api/sessions'
import { useTeamStore } from './teamStore'
import { useSessionStore } from './sessionStore'
import { useCLITaskStore } from './cliTaskStore'
+import { useTabStore } from './tabStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
@@ -11,7 +12,7 @@ import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage,
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
-type ChatStore = {
+export type PerSessionState = {
messages: UIMessage[]
chatState: ChatState
connectionState: ConnectionState
@@ -29,31 +30,11 @@ type ChatStore = {
tokenUsage: TokenUsage
elapsedSeconds: number
statusVerb: string
- connectedSessionId: string | null
slashCommands: Array<{ name: string; description: string }>
-
- // Actions
- connectToSession: (sessionId: string) => void
- disconnectSession: () => void
- sendMessage: (content: string, attachments?: AttachmentRef[]) => void
- respondToPermission: (requestId: string, allowed: boolean, rule?: string) => void
- setSessionPermissionMode: (mode: PermissionMode) => void
- stopGeneration: () => void
- loadHistory: (sessionId: string) => Promise
- clearMessages: () => void
- handleServerMessage: (msg: ServerMessage) => void
+ elapsedTimer: ReturnType | null
}
-const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
-
-/** Track tool_use IDs for task-related tools, so we can refresh on tool_result */
-const pendingTaskToolUseIds = new Set()
-
-let msgCounter = 0
-const nextId = () => `msg-${++msgCounter}-${Date.now()}`
-let elapsedTimer: ReturnType | null = null
-
-export const useChatStore = create((set, get) => ({
+const DEFAULT_SESSION_STATE: PerSessionState = {
messages: [],
chatState: 'idle',
connectionState: 'disconnected',
@@ -66,97 +47,127 @@ export const useChatStore = create((set, get) => ({
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
- connectedSessionId: null,
slashCommands: [],
+ elapsedTimer: null,
+}
- connectToSession: (sessionId: string) => {
- const current = get().connectedSessionId
- if (current === sessionId) return
+function createDefaultSessionState(): PerSessionState {
+ return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } }
+}
- // Disconnect previous
- if (current) wsManager.disconnect()
+type ChatStore = {
+ sessions: Record
- set({
- connectedSessionId: sessionId,
- connectionState: 'connecting',
- messages: [],
- chatState: 'idle',
- streamingText: '',
- streamingToolInput: '',
- activeToolUseId: null,
- activeToolName: null,
- activeThinkingId: null,
- pendingPermission: null,
- elapsedSeconds: 0,
- slashCommands: [],
- })
+ getSession: (sessionId: string) => PerSessionState
+ connectToSession: (sessionId: string) => void
+ disconnectSession: (sessionId: string) => void
+ sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
+ respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void
+ setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
+ stopGeneration: (sessionId: string) => void
+ loadHistory: (sessionId: string) => Promise
+ clearMessages: (sessionId: string) => void
+ handleServerMessage: (sessionId: string, msg: ServerMessage) => void
+}
- // Clear all previous handlers before registering new ones
- // This prevents handler accumulation causing message duplication
- wsManager.clearHandlers()
+const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
+const pendingTaskToolUseIds = new Set()
+
+let msgCounter = 0
+const nextId = () => `msg-${++msgCounter}-${Date.now()}`
+
+/** Helper: immutably update a specific session within the sessions record */
+function updateSessionIn(
+ sessions: Record,
+ sessionId: string,
+ updater: (s: PerSessionState) => Partial,
+): Record {
+ const session = sessions[sessionId]
+ if (!session) return sessions
+ return { ...sessions, [sessionId]: { ...session, ...updater(session) } }
+}
+
+export const useChatStore = create((set, get) => ({
+ sessions: {},
+
+ getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(),
+
+ connectToSession: (sessionId) => {
+ const existing = get().sessions[sessionId]
+ if (existing && existing.connectionState !== 'disconnected') return
+
+ set((s) => ({
+ sessions: {
+ ...s.sessions,
+ [sessionId]: {
+ ...createDefaultSessionState(),
+ connectionState: 'connecting',
+ messages: existing?.messages ?? [],
+ },
+ },
+ }))
+
+ wsManager.clearHandlers(sessionId)
wsManager.connect(sessionId)
- wsManager.onMessage((msg) => {
+ wsManager.onMessage(sessionId, (msg) => {
if (msg.type === 'connected') {
- set({ connectionState: 'connected' })
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ connectionState: 'connected' })) }))
}
- get().handleServerMessage(msg)
+ get().handleServerMessage(sessionId, msg)
})
- // Load history and tasks
get().loadHistory(sessionId)
useCLITaskStore.getState().fetchSessionTasks(sessionId)
sessionsApi.getSlashCommands(sessionId)
.then(({ commands }) => {
- if (get().connectedSessionId === sessionId) {
- set({ slashCommands: commands })
+ if (get().sessions[sessionId]) {
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) }))
}
})
.catch(() => {
- if (get().connectedSessionId === sessionId) {
- set({ slashCommands: [] })
+ if (get().sessions[sessionId]) {
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) }))
}
})
},
- disconnectSession: () => {
- wsManager.disconnect()
- if (elapsedTimer) clearInterval(elapsedTimer)
- useCLITaskStore.getState().clearTasks()
- set({ connectedSessionId: null, chatState: 'idle' })
+ disconnectSession: (sessionId) => {
+ const session = get().sessions[sessionId]
+ if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
+ wsManager.disconnect(sessionId)
+ set((s) => {
+ const { [sessionId]: _, ...rest } = s.sessions
+ return { sessions: rest }
+ })
},
- sendMessage: (content: string, attachments?: AttachmentRef[]) => {
+ sendMessage: (sessionId, content, attachments?) => {
const userFacingContent = content.trim()
const uiAttachments: UIAttachment[] | undefined =
attachments && attachments.length > 0
- ? attachments.map((attachment) => ({
- type: attachment.type,
- name: attachment.name || attachment.path || attachment.mimeType || attachment.type,
- data: attachment.data,
- mimeType: attachment.mimeType,
+ ? attachments.map((a) => ({
+ type: a.type,
+ name: a.name || a.path || a.mimeType || a.type,
+ data: a.data,
+ mimeType: a.mimeType,
}))
: undefined
- // If all tasks are completed, inline the task summary before the new user message
const taskStore = useCLITaskStore.getState()
const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed')
- // Add user message to UI (with optional task summary before it)
set((s) => {
- const newMessages = [...s.messages]
+ const session = s.sessions[sessionId]
+ if (!session) return s
+
+ const newMessages = [...session.messages]
if (allTasksDone) {
newMessages.push({
id: nextId(),
type: 'task_summary',
- tasks: taskStore.tasks.map((t) => ({
- id: t.id,
- subject: t.subject,
- status: t.status,
- activeForm: t.activeForm,
- })),
+ tasks: taskStore.tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })),
timestamp: Date.now(),
})
- // Clear sticky task bar since we inlined the summary
taskStore.clearTasks()
}
newMessages.push({
@@ -166,64 +177,66 @@ export const useChatStore = create((set, get) => ({
attachments: uiAttachments,
timestamp: Date.now(),
})
+
+ if (session.elapsedTimer) clearInterval(session.elapsedTimer)
+
+ const timer = setInterval(() => {
+ set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) }))
+ }, 1000)
+
return {
- messages: newMessages,
- chatState: 'thinking',
- elapsedSeconds: 0,
- streamingText: '',
- statusVerb: randomSpinnerVerb(),
+ sessions: {
+ ...s.sessions,
+ [sessionId]: {
+ ...session,
+ messages: newMessages,
+ chatState: 'thinking',
+ elapsedSeconds: 0,
+ streamingText: '',
+ statusVerb: randomSpinnerVerb(),
+ elapsedTimer: timer,
+ },
+ },
}
})
- // Start elapsed timer
- if (elapsedTimer) clearInterval(elapsedTimer)
- elapsedTimer = setInterval(() => {
- set((s) => ({ elapsedSeconds: s.elapsedSeconds + 1 }))
- }, 1000)
-
- wsManager.send({ type: 'user_message', content, attachments })
+ wsManager.send(sessionId, { type: 'user_message', content, attachments })
},
- respondToPermission: (requestId: string, allowed: boolean, rule?: string) => {
- wsManager.send({ type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
- set({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })
+ respondToPermission: (sessionId, requestId, allowed, rule?) => {
+ wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
},
- setSessionPermissionMode: (mode) => {
- if (!get().connectedSessionId) return
- wsManager.send({ type: 'set_permission_mode', mode })
+ setSessionPermissionMode: (sessionId, mode) => {
+ if (!get().sessions[sessionId]) return
+ wsManager.send(sessionId, { type: 'set_permission_mode', mode })
},
- stopGeneration: () => {
- wsManager.send({ type: 'stop_generation' })
- if (elapsedTimer) clearInterval(elapsedTimer)
- set({ chatState: 'idle' })
+ stopGeneration: (sessionId) => {
+ wsManager.send(sessionId, { type: 'stop_generation' })
+ set((s) => {
+ const session = s.sessions[sessionId]
+ if (!session) return s
+ if (session.elapsedTimer) clearInterval(session.elapsedTimer)
+ return { sessions: { ...s.sessions, [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null } } }
+ })
},
- loadHistory: async (sessionId: string) => {
+ loadHistory: async (sessionId) => {
try {
const { messages } = await sessionsApi.getMessages(sessionId)
const uiMessages = mapHistoryMessagesToUiMessages(messages)
-
set((state) => {
- if (state.connectedSessionId !== sessionId || state.messages.length > 0) {
- return state
- }
- return { ...state, messages: uiMessages }
+ const session = state.sessions[sessionId]
+ if (!session || session.messages.length > 0) return state
+ return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages })) }
})
-
- // Extract the last TodoWrite input from history so TaskBar shows for V1 sessions
const lastTodos = extractLastTodoWriteFromHistory(messages)
if (lastTodos && lastTodos.length > 0) {
const taskStore = useCLITaskStore.getState()
- // Only set if V2 task fetch didn't already populate tasks
- if (taskStore.tasks.length === 0) {
- taskStore.setTasksFromTodos(lastTodos)
- }
+ if (taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos)
}
-
- // For both V1 and V2: if all tasks completed and the user already
- // continued chatting, suppress the sticky TaskBar on page refresh.
if (hasUserMessagesAfterTaskCompletion(messages)) {
useCLITaskStore.getState().markCompletedAndDismissed()
}
@@ -232,89 +245,79 @@ export const useChatStore = create((set, get) => ({
}
},
- clearMessages: () => set({ messages: [], streamingText: '', chatState: 'idle' }),
+ clearMessages: (sessionId) => {
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) }))
+ },
+
+ handleServerMessage: (sessionId, msg) => {
+ const update = (updater: (session: PerSessionState) => Partial) => {
+ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) }))
+ }
- handleServerMessage: (msg: ServerMessage) => {
switch (msg.type) {
case 'connected':
break
case 'status':
- set({
+ update((session) => ({
chatState: msg.state,
- // Only override statusVerb if the server sends something other than
- // the generic 'Thinking' — otherwise keep the random verb we picked
- // in sendMessage so the user sees fun loading text.
...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}),
- ...(msg.tokens ? { tokenUsage: { ...get().tokenUsage, output_tokens: msg.tokens } } : {}),
+ ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
- })
- if (msg.state === 'idle' && elapsedTimer) {
- clearInterval(elapsedTimer)
- elapsedTimer = null
+ }))
+ if (msg.state === 'idle') {
+ const session = get().sessions[sessionId]
+ if (session?.elapsedTimer) {
+ clearInterval(session.elapsedTimer)
+ update(() => ({ elapsedTimer: null }))
+ }
}
+ // Sync tab status
+ useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
break
case 'content_start': {
- // Flush any accumulated streamingText as assistant_text BEFORE
- // switching to the next block. This preserves intermediate text
- // segments between tool calls (e.g. "项目已经有 node_modules,直接启动").
- const pendingText = get().streamingText.trim()
+ const session = get().sessions[sessionId]
+ if (!session) break
+ const pendingText = session.streamingText.trim()
if (pendingText) {
- set((s) => ({
- messages: [...s.messages, {
- id: nextId(),
- type: 'assistant_text',
- content: pendingText,
- timestamp: Date.now(),
- }],
+ update((s) => ({
+ messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }],
streamingText: '',
}))
}
-
if (msg.blockType === 'text') {
- set({ streamingText: '', chatState: 'streaming', activeThinkingId: null })
+ update(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null }))
} else if (msg.blockType === 'tool_use') {
- set({
+ update(() => ({
activeToolUseId: msg.toolUseId ?? null,
activeToolName: msg.toolName ?? null,
streamingToolInput: '',
chatState: 'tool_executing',
activeThinkingId: null,
- })
+ }))
}
break
}
case 'content_delta':
- if (msg.text !== undefined) {
- set((s) => ({ streamingText: s.streamingText + msg.text }))
- }
- if (msg.toolInput !== undefined) {
- set((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
- }
+ if (msg.text !== undefined) update((s) => ({ streamingText: s.streamingText + msg.text }))
+ if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
break
case 'thinking':
- // Merge consecutive thinking deltas into one message.
- // Also flush any pending streamingText first — otherwise the text
- // becomes invisible because MessageList only renders streamingText
- // when chatState === 'streaming', and we're about to set it to 'thinking'.
- set((s) => {
+ update((s) => {
const pendingText = s.streamingText.trim()
const base = pendingText
? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }]
: s.messages
-
const last = base[base.length - 1]
if (last && last.type === 'thinking') {
- // Append to existing thinking message
const updated = [...base]
updated[updated.length - 1] = { ...last, content: last.content + msg.text }
return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' }
}
const id = nextId()
- // Create new thinking message
return {
messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }],
chatState: 'thinking',
@@ -325,49 +328,33 @@ export const useChatStore = create((set, get) => ({
break
case 'tool_use_complete': {
- const toolName = msg.toolName || get().activeToolName || 'unknown'
- set((s) => ({
+ const session = get().sessions[sessionId]
+ const toolName = msg.toolName || session?.activeToolName || 'unknown'
+ update((s) => ({
messages: [...s.messages, {
- id: nextId(),
- type: 'tool_use',
- toolName,
+ id: nextId(), type: 'tool_use', toolName,
toolUseId: msg.toolUseId || s.activeToolUseId || '',
- input: msg.input,
- timestamp: Date.now(),
- parentToolUseId: msg.parentToolUseId,
+ input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
}],
- activeToolUseId: null,
- activeToolName: null,
- activeThinkingId: null,
- streamingToolInput: '',
+ activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '',
}))
- // TodoWrite: input contains the full todo list — update tasks immediately
if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) {
useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos)
} else if (TASK_TOOL_NAMES.has(toolName)) {
- // V2 task tools — refresh will happen on tool_result
- // when the tool has actually finished executing and written to disk
- const useId = msg.toolUseId || get().activeToolUseId
+ const useId = msg.toolUseId || session?.activeToolUseId
if (useId) pendingTaskToolUseIds.add(useId)
}
break
}
case 'tool_result':
- set((s) => ({
+ update((s) => ({
messages: [...s.messages, {
- id: nextId(),
- type: 'tool_result',
- toolUseId: msg.toolUseId,
- content: msg.content,
- isError: msg.isError,
- timestamp: Date.now(),
- parentToolUseId: msg.parentToolUseId,
+ id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId,
+ content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
}],
- chatState: 'thinking',
- activeThinkingId: null,
+ chatState: 'thinking', activeThinkingId: null,
}))
- // Refresh tasks after a task tool has finished executing
if (pendingTaskToolUseIds.has(msg.toolUseId)) {
pendingTaskToolUseIds.delete(msg.toolUseId)
useCLITaskStore.getState().refreshTasks()
@@ -375,230 +362,118 @@ export const useChatStore = create((set, get) => ({
break
case 'permission_request':
- set({
- pendingPermission: {
- requestId: msg.requestId,
- toolName: msg.toolName,
- input: msg.input,
- description: msg.description,
- },
+ update((s) => ({
+ pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description },
chatState: 'permission_pending',
activeThinkingId: null,
- })
- set((s) => ({
messages: [...s.messages, {
- id: nextId(),
- type: 'permission_request',
- requestId: msg.requestId,
- toolName: msg.toolName,
- input: msg.input,
- description: msg.description,
- timestamp: Date.now(),
+ id: nextId(), type: 'permission_request', requestId: msg.requestId,
+ toolName: msg.toolName, input: msg.input, description: msg.description, timestamp: Date.now(),
}],
}))
break
case 'message_complete': {
- // Flush streaming text as a message
- const text = get().streamingText
+ const session = get().sessions[sessionId]
+ if (!session) break
+ const text = session.streamingText
if (text) {
- set((s) => ({
+ update((s) => ({
messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }],
streamingText: '',
}))
}
- set({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null })
- if (elapsedTimer) {
- clearInterval(elapsedTimer)
- elapsedTimer = null
- }
+ if (session.elapsedTimer) clearInterval(session.elapsedTimer)
+ update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null }))
break
}
case 'error':
- set((s) => ({
+ update((s) => ({
messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }],
- chatState: 'idle',
- activeThinkingId: null,
+ chatState: 'idle', activeThinkingId: null,
}))
- if (elapsedTimer) {
- clearInterval(elapsedTimer)
- elapsedTimer = null
+ useTabStore.getState().updateTabStatus(sessionId, 'error')
+ {
+ const session = get().sessions[sessionId]
+ if (session?.elapsedTimer) {
+ clearInterval(session.elapsedTimer)
+ update(() => ({ elapsedTimer: null }))
+ }
}
break
case 'team_created':
useTeamStore.getState().handleTeamCreated(msg.teamName)
break
-
case 'team_update':
useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members)
break
-
case 'team_deleted':
useTeamStore.getState().handleTeamDeleted(msg.teamName)
break
-
case 'task_update':
break
-
case 'session_title_updated':
useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title)
+ useTabStore.getState().updateTabTitle(msg.sessionId, msg.title)
break
-
case 'system_notification':
- // Cache slash commands from CLI init
if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) {
- set({ slashCommands: msg.data as Array<{ name: string; description: string }> })
+ update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> }))
}
break
-
case 'pong':
break
}
},
}))
-type AssistantHistoryBlock = {
- type: string
- text?: string
- thinking?: string
- name?: string
- id?: string
- input?: unknown
-}
+// ─── History mapping helpers (unchanged from original) ─────────
-type UserHistoryBlock = {
- type: string
- text?: string
- tool_use_id?: string
- content?: unknown
- is_error?: boolean
- source?: { data?: string }
- mimeType?: string
- media_type?: string
- name?: string
-}
+type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
+type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string }
export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] {
const uiMessages: UIMessage[] = []
-
for (const msg of messages) {
const timestamp = new Date(msg.timestamp).getTime()
-
if (msg.type === 'user' && typeof msg.content === 'string') {
- uiMessages.push({
- id: msg.id || nextId(),
- type: 'user_text',
- content: msg.content,
- timestamp,
- })
+ uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
continue
}
-
if (msg.type === 'assistant' && typeof msg.content === 'string') {
- uiMessages.push({
- id: msg.id || nextId(),
- type: 'assistant_text',
- content: msg.content,
- timestamp,
- model: msg.model,
- })
+ uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model })
continue
}
-
- // Server marks assistant messages containing tool_use blocks as type 'tool_use',
- // but the content array structure is the same as 'assistant' — handle both.
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
for (const block of msg.content as AssistantHistoryBlock[]) {
- if (block.type === 'thinking' && block.thinking) {
- uiMessages.push({
- id: nextId(),
- type: 'thinking',
- content: block.thinking,
- timestamp,
- })
- } else if (block.type === 'text' && block.text) {
- uiMessages.push({
- id: nextId(),
- type: 'assistant_text',
- content: block.text,
- timestamp,
- model: msg.model,
- })
- } else if (block.type === 'tool_use') {
- uiMessages.push({
- id: nextId(),
- type: 'tool_use',
- toolName: block.name ?? 'unknown',
- toolUseId: block.id ?? '',
- input: block.input,
- timestamp,
- parentToolUseId: msg.parentToolUseId,
- })
- }
+ if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
+ else if (block.type === 'text' && block.text) uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model })
+ else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId })
}
continue
}
-
- // Server marks user messages containing tool_result blocks as type 'tool_result',
- // but the content array structure is the same as 'user' — handle both.
if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) {
const textParts: string[] = []
const attachments: UIAttachment[] = []
-
for (const block of msg.content as UserHistoryBlock[]) {
- if (block.type === 'text' && block.text) {
- textParts.push(block.text)
- } else if (block.type === 'image') {
- attachments.push({
- type: 'image',
- name: block.name || 'image',
- data: block.source?.data,
- mimeType: block.mimeType || block.media_type,
- })
- } else if (block.type === 'file') {
- attachments.push({
- type: 'file',
- name: block.name || 'file',
- })
- } else if (block.type === 'tool_result') {
- uiMessages.push({
- id: nextId(),
- type: 'tool_result',
- toolUseId: block.tool_use_id ?? '',
- content: block.content,
- isError: !!block.is_error,
- timestamp,
- parentToolUseId: msg.parentToolUseId,
- })
- }
+ if (block.type === 'text' && block.text) textParts.push(block.text)
+ else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type })
+ else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' })
+ else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
}
-
if (textParts.length > 0 || attachments.length > 0) {
- uiMessages.push({
- id: nextId(),
- type: 'user_text',
- content: textParts.join('\n'),
- attachments: attachments.length > 0 ? attachments : undefined,
- timestamp,
- })
+ uiMessages.push({ id: nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp })
}
}
}
-
return uiMessages
}
-/** Scan history messages for the last TodoWrite tool_use and return the todos array.
- * Returns null if all todos were completed and the user continued chatting after. */
-function extractLastTodoWriteFromHistory(
- messages: MessageEntry[],
-): Array<{ content: string; status: string; activeForm?: string }> | null {
+function extractLastTodoWriteFromHistory(messages: MessageEntry[]): Array<{ content: string; status: string; activeForm?: string }> | null {
let foundIndex = -1
let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null
-
- // Walk backwards to find the most recent TodoWrite
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
@@ -617,45 +492,28 @@ function extractLastTodoWriteFromHistory(
if (todos) break
}
}
-
if (!todos) return null
-
- // If all todos are completed and there are user messages after, the tasks
- // were already inlined — don't re-show the sticky bar.
const allDone = todos.every((t) => t.status === 'completed')
if (allDone) {
for (let i = foundIndex + 1; i < messages.length; i++) {
- const msg = messages[i]!
- if (msg.type === 'user' && msg.content) return null
+ if (messages[i]!.type === 'user' && messages[i]!.content) return null
}
}
-
return todos
}
const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
-/** Check if there are user messages after the last task-related tool call.
- * Used to determine if completed tasks should be shown as sticky or inline. */
function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean {
- // Find the index of the last task-related tool call
let lastTaskIndex = -1
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
const blocks = msg.content as AssistantHistoryBlock[]
- if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) {
- lastTaskIndex = i
- break
- }
+ if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break }
}
}
-
if (lastTaskIndex < 0) return false
-
- // Check for user messages after the last task tool
- for (let i = lastTaskIndex + 1; i < messages.length; i++) {
- if (messages[i]!.type === 'user') return true
- }
+ for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true }
return false
}
diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts
new file mode 100644
index 00000000..3c531e6d
--- /dev/null
+++ b/desktop/src/stores/tabStore.ts
@@ -0,0 +1,128 @@
+import { create } from 'zustand'
+import { sessionsApi } from '../api/sessions'
+
+const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
+
+export type Tab = {
+ sessionId: string
+ title: string
+ status: 'idle' | 'running' | 'error'
+}
+
+type TabPersistence = {
+ openTabs: Array<{ sessionId: string; title: string }>
+ activeTabId: string | null
+}
+
+type TabStore = {
+ tabs: Tab[]
+ activeTabId: string | null
+
+ openTab: (sessionId: string, title: string) => void
+ closeTab: (sessionId: string) => void
+ setActiveTab: (sessionId: string) => void
+ updateTabTitle: (sessionId: string, title: string) => void
+ updateTabStatus: (sessionId: string, status: Tab['status']) => void
+
+ saveTabs: () => void
+ restoreTabs: () => Promise
+}
+
+export const useTabStore = create((set, get) => ({
+ tabs: [],
+ activeTabId: null,
+
+ openTab: (sessionId, title) => {
+ const { tabs } = get()
+ const existing = tabs.find((t) => t.sessionId === sessionId)
+ if (existing) {
+ set({ activeTabId: sessionId })
+ } else {
+ set({
+ tabs: [...tabs, { sessionId, title, status: 'idle' }],
+ activeTabId: sessionId,
+ })
+ }
+ get().saveTabs()
+ },
+
+ closeTab: (sessionId) => {
+ const { tabs, activeTabId } = get()
+ const index = tabs.findIndex((t) => t.sessionId === sessionId)
+ if (index < 0) return
+
+ const newTabs = tabs.filter((t) => t.sessionId !== sessionId)
+ let newActiveId = activeTabId
+
+ if (activeTabId === sessionId) {
+ if (newTabs.length === 0) {
+ newActiveId = null
+ } else if (index >= newTabs.length) {
+ newActiveId = newTabs[newTabs.length - 1]!.sessionId
+ } else {
+ newActiveId = newTabs[index]!.sessionId
+ }
+ }
+
+ set({ tabs: newTabs, activeTabId: newActiveId })
+ get().saveTabs()
+ },
+
+ setActiveTab: (sessionId) => {
+ set({ activeTabId: sessionId })
+ get().saveTabs()
+ },
+
+ updateTabTitle: (sessionId, title) => {
+ set((s) => ({
+ tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, title } : t)),
+ }))
+ get().saveTabs()
+ },
+
+ updateTabStatus: (sessionId, status) => {
+ set((s) => ({
+ tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, status } : t)),
+ }))
+ },
+
+ saveTabs: () => {
+ const { tabs, activeTabId } = get()
+ const data: TabPersistence = {
+ openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title })),
+ activeTabId,
+ }
+ try {
+ localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data))
+ } catch { /* noop */ }
+ },
+
+ restoreTabs: async () => {
+ try {
+ const raw = localStorage.getItem(TAB_STORAGE_KEY)
+ if (!raw) return
+
+ const data = JSON.parse(raw) as TabPersistence
+ if (!data.openTabs || data.openTabs.length === 0) return
+
+ const { sessions } = await sessionsApi.list({ limit: 200 })
+ const existingIds = new Set(sessions.map((s) => s.id))
+
+ const validTabs: Tab[] = data.openTabs
+ .filter((t) => existingIds.has(t.sessionId))
+ .map((t) => ({
+ sessionId: t.sessionId,
+ title: sessions.find((s) => s.id === t.sessionId)?.title || t.title,
+ status: 'idle' as const,
+ }))
+
+ if (validTabs.length === 0) return
+
+ const activeId = data.activeTabId && validTabs.some((t) => t.sessionId === data.activeTabId)
+ ? data.activeTabId
+ : validTabs[0]!.sessionId
+
+ set({ tabs: validTabs, activeTabId: activeId })
+ } catch { /* noop */ }
+ },
+}))
diff --git a/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md b/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md
new file mode 100644
index 00000000..0af6a9f3
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md
@@ -0,0 +1,1814 @@
+# Multi-Tab Sessions Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Desktop webapp 支持多 Tab 并行 Session,每个 Tab 独立 WebSocket 连接和消息状态。
+
+**Architecture:** 新增 tabStore 管理 Tab 状态;将 chatStore 从单 session 改为 per-session Map;将 wsManager 从单例连接改为多连接管理器;新增 TabBar 组件放在内容区域顶部;修改 Sidebar 点击行为为 openTab 模式。
+
+**Tech Stack:** React 18, Zustand 5, Tailwind CSS 4, TypeScript, Vite
+
+---
+
+### Task 1: WebSocket Manager 多连接重构
+
+**Files:**
+- Modify: `desktop/src/api/websocket.ts`
+
+- [ ] **Step 1: 重构 WebSocketManager 为多连接管理器**
+
+将单例 WS 连接改为 `Map` 模式。每个方法都以 sessionId 为 key。
+
+```ts
+import type { ClientMessage, ServerMessage } from '../types/chat'
+import { getBaseUrl } from './client'
+
+type MessageHandler = (msg: ServerMessage) => void
+
+type Connection = {
+ ws: WebSocket
+ handlers: Set
+ reconnectTimer: ReturnType | null
+ reconnectAttempt: number
+ pingInterval: ReturnType | null
+ intentionalClose: boolean
+ pendingMessages: ClientMessage[]
+}
+
+class WebSocketManager {
+ private connections = new Map()
+
+ isConnected(sessionId: string): boolean {
+ const conn = this.connections.get(sessionId)
+ return conn?.ws.readyState === WebSocket.OPEN
+ }
+
+ getConnectedSessionIds(): string[] {
+ return [...this.connections.keys()]
+ }
+
+ connect(sessionId: string) {
+ // Already connected or connecting
+ const existing = this.connections.get(sessionId)
+ if (existing && !existing.intentionalClose) return
+
+ const conn: Connection = {
+ ws: null as unknown as WebSocket,
+ handlers: new Set(),
+ reconnectTimer: null,
+ reconnectAttempt: 0,
+ pingInterval: null,
+ intentionalClose: false,
+ pendingMessages: [],
+ }
+ this.connections.set(sessionId, conn)
+
+ const wsUrl = getBaseUrl().replace(/^http/, 'ws')
+ conn.ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
+
+ conn.ws.onopen = () => {
+ conn.reconnectAttempt = 0
+ this.startPingLoop(sessionId)
+ while (conn.pendingMessages.length > 0) {
+ const msg = conn.pendingMessages.shift()!
+ conn.ws.send(JSON.stringify(msg))
+ }
+ }
+
+ conn.ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data as string) as ServerMessage
+ for (const handler of conn.handlers) {
+ handler(msg)
+ }
+ } catch {
+ // Ignore malformed messages
+ }
+ }
+
+ conn.ws.onclose = () => {
+ this.stopPingLoop(sessionId)
+ if (!conn.intentionalClose) {
+ this.scheduleReconnect(sessionId)
+ }
+ }
+
+ conn.ws.onerror = () => {
+ // onclose will fire after onerror
+ }
+ }
+
+ disconnect(sessionId: string) {
+ const conn = this.connections.get(sessionId)
+ if (!conn) return
+
+ conn.intentionalClose = true
+ this.stopPingLoop(sessionId)
+ this.clearReconnect(sessionId)
+ conn.pendingMessages = []
+
+ if (conn.ws) {
+ conn.ws.close()
+ }
+ this.connections.delete(sessionId)
+ }
+
+ disconnectAll() {
+ for (const sessionId of [...this.connections.keys()]) {
+ this.disconnect(sessionId)
+ }
+ }
+
+ send(sessionId: string, message: ClientMessage) {
+ const conn = this.connections.get(sessionId)
+ if (!conn) return
+
+ if (conn.ws.readyState === WebSocket.OPEN) {
+ conn.ws.send(JSON.stringify(message))
+ } else if (conn.ws.readyState === WebSocket.CONNECTING) {
+ conn.pendingMessages.push(message)
+ }
+ }
+
+ onMessage(sessionId: string, handler: MessageHandler): () => void {
+ const conn = this.connections.get(sessionId)
+ if (!conn) return () => {}
+ conn.handlers.add(handler)
+ return () => { conn.handlers.delete(handler) }
+ }
+
+ clearHandlers(sessionId: string) {
+ const conn = this.connections.get(sessionId)
+ if (conn) conn.handlers.clear()
+ }
+
+ private startPingLoop(sessionId: string) {
+ this.stopPingLoop(sessionId)
+ const conn = this.connections.get(sessionId)
+ if (!conn) return
+ conn.pingInterval = setInterval(() => {
+ this.send(sessionId, { type: 'ping' })
+ }, 30_000)
+ }
+
+ private stopPingLoop(sessionId: string) {
+ const conn = this.connections.get(sessionId)
+ if (conn?.pingInterval) {
+ clearInterval(conn.pingInterval)
+ conn.pingInterval = null
+ }
+ }
+
+ private scheduleReconnect(sessionId: string) {
+ this.clearReconnect(sessionId)
+ const conn = this.connections.get(sessionId)
+ if (!conn) return
+
+ const delay = Math.min(1000 * 2 ** conn.reconnectAttempt, 30_000)
+ conn.reconnectAttempt++
+
+ conn.reconnectTimer = setTimeout(() => {
+ if (this.connections.has(sessionId) && !conn.intentionalClose) {
+ // Re-create connection
+ this.connections.delete(sessionId)
+ this.connect(sessionId)
+ // Re-register handlers from old connection
+ const newConn = this.connections.get(sessionId)
+ if (newConn) {
+ for (const handler of conn.handlers) {
+ newConn.handlers.add(handler)
+ }
+ }
+ }
+ }, delay)
+ }
+
+ private clearReconnect(sessionId: string) {
+ const conn = this.connections.get(sessionId)
+ if (conn?.reconnectTimer) {
+ clearTimeout(conn.reconnectTimer)
+ conn.reconnectTimer = null
+ }
+ }
+}
+
+export const wsManager = new WebSocketManager()
+```
+
+- [ ] **Step 2: 验证编译通过**
+
+Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit 2>&1 | head -30`
+Expected: chatStore.ts 会有类型错误(因为调用方式变了),这是预期的,Task 2 会修复。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/src/api/websocket.ts
+git commit -m "refactor: websocket manager supports multiple concurrent connections"
+```
+
+---
+
+### Task 2: chatStore 多 Session 状态隔离
+
+**Files:**
+- Modify: `desktop/src/stores/chatStore.ts`
+
+- [ ] **Step 1: 重构 chatStore 为 per-session 状态管理**
+
+将 `messages`, `chatState`, `streamingText` 等状态从顶层单一值改为按 sessionId 隔离的 Map 结构。
+
+```ts
+import { create } from 'zustand'
+import { wsManager } from '../api/websocket'
+import { sessionsApi } from '../api/sessions'
+import { useTeamStore } from './teamStore'
+import { useSessionStore } from './sessionStore'
+import { useCLITaskStore } from './cliTaskStore'
+import { randomSpinnerVerb } from '../config/spinnerVerbs'
+import type { MessageEntry } from '../types/session'
+import type { PermissionMode } from '../types/settings'
+import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat'
+
+type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
+
+export type PerSessionState = {
+ messages: UIMessage[]
+ chatState: ChatState
+ connectionState: ConnectionState
+ streamingText: string
+ streamingToolInput: string
+ activeToolUseId: string | null
+ activeToolName: string | null
+ activeThinkingId: string | null
+ pendingPermission: {
+ requestId: string
+ toolName: string
+ input: unknown
+ description?: string
+ } | null
+ tokenUsage: TokenUsage
+ elapsedSeconds: number
+ statusVerb: string
+ slashCommands: Array<{ name: string; description: string }>
+ elapsedTimer: ReturnType | null
+}
+
+function createDefaultSessionState(): PerSessionState {
+ return {
+ messages: [],
+ chatState: 'idle',
+ connectionState: 'disconnected',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: null,
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
+ elapsedSeconds: 0,
+ statusVerb: '',
+ slashCommands: [],
+ elapsedTimer: null,
+ }
+}
+
+type ChatStore = {
+ sessions: Record
+
+ // Actions — all scoped by sessionId
+ connectToSession: (sessionId: string) => void
+ disconnectSession: (sessionId: string) => void
+ sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
+ respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void
+ setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
+ stopGeneration: (sessionId: string) => void
+ loadHistory: (sessionId: string) => Promise
+ clearMessages: (sessionId: string) => void
+ handleServerMessage: (sessionId: string, msg: ServerMessage) => void
+
+ // Helpers
+ getSession: (sessionId: string) => PerSessionState
+}
+
+const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
+const pendingTaskToolUseIds = new Set()
+
+let msgCounter = 0
+const nextId = () => `msg-${++msgCounter}-${Date.now()}`
+
+export const useChatStore = create((set, get) => ({
+ sessions: {},
+
+ getSession: (sessionId: string): PerSessionState => {
+ return get().sessions[sessionId] ?? createDefaultSessionState()
+ },
+
+ connectToSession: (sessionId: string) => {
+ const existing = get().sessions[sessionId]
+ if (existing && existing.connectionState !== 'disconnected') return
+
+ // Initialize session state
+ set((s) => ({
+ sessions: {
+ ...s.sessions,
+ [sessionId]: {
+ ...createDefaultSessionState(),
+ connectionState: 'connecting',
+ // Preserve messages if reconnecting
+ messages: existing?.messages ?? [],
+ },
+ },
+ }))
+
+ wsManager.clearHandlers(sessionId)
+ wsManager.connect(sessionId)
+ wsManager.onMessage(sessionId, (msg) => {
+ if (msg.type === 'connected') {
+ set((s) => ({
+ sessions: {
+ ...s.sessions,
+ [sessionId]: { ...s.sessions[sessionId]!, connectionState: 'connected' },
+ },
+ }))
+ }
+ get().handleServerMessage(sessionId, msg)
+ })
+
+ // Load history and tasks
+ get().loadHistory(sessionId)
+ useCLITaskStore.getState().fetchSessionTasks(sessionId)
+ sessionsApi.getSlashCommands(sessionId)
+ .then(({ commands }) => {
+ const current = get().sessions[sessionId]
+ if (current) {
+ set((s) => ({
+ sessions: {
+ ...s.sessions,
+ [sessionId]: { ...s.sessions[sessionId]!, slashCommands: commands },
+ },
+ }))
+ }
+ })
+ .catch(() => {
+ const current = get().sessions[sessionId]
+ if (current) {
+ set((s) => ({
+ sessions: {
+ ...s.sessions,
+ [sessionId]: { ...s.sessions[sessionId]!, slashCommands: [] },
+ },
+ }))
+ }
+ })
+ },
+
+ disconnectSession: (sessionId: string) => {
+ const session = get().sessions[sessionId]
+ if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
+
+ wsManager.disconnect(sessionId)
+
+ set((s) => {
+ const { [sessionId]: _, ...rest } = s.sessions
+ return { sessions: rest }
+ })
+ },
+
+ sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => {
+ const userFacingContent = content.trim()
+ const uiAttachments: UIAttachment[] | undefined =
+ attachments && attachments.length > 0
+ ? attachments.map((attachment) => ({
+ type: attachment.type,
+ name: attachment.name || attachment.path || attachment.mimeType || attachment.type,
+ data: attachment.data,
+ mimeType: attachment.mimeType,
+ }))
+ : undefined
+
+ const taskStore = useCLITaskStore.getState()
+ const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed')
+
+ set((s) => {
+ const session = s.sessions[sessionId]
+ if (!session) return s
+
+ const newMessages = [...session.messages]
+ if (allTasksDone) {
+ newMessages.push({
+ id: nextId(),
+ type: 'task_summary',
+ tasks: taskStore.tasks.map((t) => ({
+ id: t.id,
+ subject: t.subject,
+ status: t.status,
+ activeForm: t.activeForm,
+ })),
+ timestamp: Date.now(),
+ })
+ taskStore.clearTasks()
+ }
+ newMessages.push({
+ id: nextId(),
+ type: 'user_text',
+ content: userFacingContent,
+ attachments: uiAttachments,
+ timestamp: Date.now(),
+ })
+
+ // Clear old timer
+ if (session.elapsedTimer) clearInterval(session.elapsedTimer)
+
+ // Start new elapsed timer
+ const timer = setInterval(() => {
+ set((st) => {
+ const sess = st.sessions[sessionId]
+ if (!sess) return st
+ return {
+ sessions: {
+ ...st.sessions,
+ [sessionId]: { ...sess, elapsedSeconds: sess.elapsedSeconds + 1 },
+ },
+ }
+ })
+ }, 1000)
+
+ return {
+ sessions: {
+ ...s.sessions,
+ [sessionId]: {
+ ...session,
+ messages: newMessages,
+ chatState: 'thinking',
+ elapsedSeconds: 0,
+ streamingText: '',
+ statusVerb: randomSpinnerVerb(),
+ elapsedTimer: timer,
+ },
+ },
+ }
+ })
+
+ wsManager.send(sessionId, { type: 'user_message', content, attachments })
+ },
+
+ respondToPermission: (sessionId, requestId, allowed, rule?) => {
+ wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
+ set((s) => {
+ const session = s.sessions[sessionId]
+ if (!session) return s
+ return {
+ sessions: {
+ ...s.sessions,
+ [sessionId]: { ...session, pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' },
+ },
+ }
+ })
+ },
+
+ setSessionPermissionMode: (sessionId, mode) => {
+ if (!get().sessions[sessionId]) return
+ wsManager.send(sessionId, { type: 'set_permission_mode', mode })
+ },
+
+ stopGeneration: (sessionId) => {
+ wsManager.send(sessionId, { type: 'stop_generation' })
+ set((s) => {
+ const session = s.sessions[sessionId]
+ if (!session) return s
+ if (session.elapsedTimer) clearInterval(session.elapsedTimer)
+ return {
+ sessions: {
+ ...s.sessions,
+ [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null },
+ },
+ }
+ })
+ },
+
+ loadHistory: async (sessionId: string) => {
+ try {
+ const { messages } = await sessionsApi.getMessages(sessionId)
+ const uiMessages = mapHistoryMessagesToUiMessages(messages)
+
+ set((state) => {
+ const session = state.sessions[sessionId]
+ if (!session || session.messages.length > 0) return state
+ return {
+ sessions: {
+ ...state.sessions,
+ [sessionId]: { ...session, messages: uiMessages },
+ },
+ }
+ })
+
+ const lastTodos = extractLastTodoWriteFromHistory(messages)
+ if (lastTodos && lastTodos.length > 0) {
+ const taskStore = useCLITaskStore.getState()
+ if (taskStore.tasks.length === 0) {
+ taskStore.setTasksFromTodos(lastTodos)
+ }
+ }
+
+ if (hasUserMessagesAfterTaskCompletion(messages)) {
+ useCLITaskStore.getState().markCompletedAndDismissed()
+ }
+ } catch {
+ // Session may not have messages yet
+ }
+ },
+
+ clearMessages: (sessionId) => {
+ set((s) => {
+ const session = s.sessions[sessionId]
+ if (!session) return s
+ return {
+ sessions: {
+ ...s.sessions,
+ [sessionId]: { ...session, messages: [], streamingText: '', chatState: 'idle' },
+ },
+ }
+ })
+ },
+
+ handleServerMessage: (sessionId: string, msg: ServerMessage) => {
+ const updateSession = (updater: (session: PerSessionState) => Partial) => {
+ set((s) => {
+ const session = s.sessions[sessionId]
+ if (!session) return s
+ return {
+ sessions: {
+ ...s.sessions,
+ [sessionId]: { ...session, ...updater(session) },
+ },
+ }
+ })
+ }
+
+ switch (msg.type) {
+ case 'connected':
+ break
+
+ case 'status':
+ updateSession((session) => ({
+ chatState: msg.state,
+ ...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}),
+ ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
+ ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
+ }))
+ if (msg.state === 'idle') {
+ const session = get().sessions[sessionId]
+ if (session?.elapsedTimer) {
+ clearInterval(session.elapsedTimer)
+ updateSession(() => ({ elapsedTimer: null }))
+ }
+ }
+ break
+
+ case 'content_start': {
+ const session = get().sessions[sessionId]
+ if (!session) break
+
+ const pendingText = session.streamingText.trim()
+ if (pendingText) {
+ updateSession((s) => ({
+ messages: [...s.messages, {
+ id: nextId(),
+ type: 'assistant_text' as const,
+ content: pendingText,
+ timestamp: Date.now(),
+ }],
+ streamingText: '',
+ }))
+ }
+
+ if (msg.blockType === 'text') {
+ updateSession(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null }))
+ } else if (msg.blockType === 'tool_use') {
+ updateSession(() => ({
+ activeToolUseId: msg.toolUseId ?? null,
+ activeToolName: msg.toolName ?? null,
+ streamingToolInput: '',
+ chatState: 'tool_executing',
+ activeThinkingId: null,
+ }))
+ }
+ break
+ }
+
+ case 'content_delta':
+ if (msg.text !== undefined) {
+ updateSession((s) => ({ streamingText: s.streamingText + msg.text }))
+ }
+ if (msg.toolInput !== undefined) {
+ updateSession((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
+ }
+ break
+
+ case 'thinking':
+ updateSession((s) => {
+ const pendingText = s.streamingText.trim()
+ const base = pendingText
+ ? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }]
+ : s.messages
+
+ const last = base[base.length - 1]
+ if (last && last.type === 'thinking') {
+ const updated = [...base]
+ updated[updated.length - 1] = { ...last, content: last.content + msg.text }
+ return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' }
+ }
+ const id = nextId()
+ return {
+ messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }],
+ chatState: 'thinking',
+ activeThinkingId: id,
+ streamingText: '',
+ }
+ })
+ break
+
+ case 'tool_use_complete': {
+ const session = get().sessions[sessionId]
+ const toolName = msg.toolName || session?.activeToolName || 'unknown'
+ updateSession((s) => ({
+ messages: [...s.messages, {
+ id: nextId(),
+ type: 'tool_use',
+ toolName,
+ toolUseId: msg.toolUseId || s.activeToolUseId || '',
+ input: msg.input,
+ timestamp: Date.now(),
+ parentToolUseId: msg.parentToolUseId,
+ }],
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ streamingToolInput: '',
+ }))
+ if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) {
+ useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos)
+ } else if (TASK_TOOL_NAMES.has(toolName)) {
+ const useId = msg.toolUseId || session?.activeToolUseId
+ if (useId) pendingTaskToolUseIds.add(useId)
+ }
+ break
+ }
+
+ case 'tool_result':
+ updateSession((s) => ({
+ messages: [...s.messages, {
+ id: nextId(),
+ type: 'tool_result',
+ toolUseId: msg.toolUseId,
+ content: msg.content,
+ isError: msg.isError,
+ timestamp: Date.now(),
+ parentToolUseId: msg.parentToolUseId,
+ }],
+ chatState: 'thinking',
+ activeThinkingId: null,
+ }))
+ if (pendingTaskToolUseIds.has(msg.toolUseId)) {
+ pendingTaskToolUseIds.delete(msg.toolUseId)
+ useCLITaskStore.getState().refreshTasks()
+ }
+ break
+
+ case 'permission_request':
+ updateSession((s) => ({
+ pendingPermission: {
+ requestId: msg.requestId,
+ toolName: msg.toolName,
+ input: msg.input,
+ description: msg.description,
+ },
+ chatState: 'permission_pending',
+ activeThinkingId: null,
+ messages: [...s.messages, {
+ id: nextId(),
+ type: 'permission_request',
+ requestId: msg.requestId,
+ toolName: msg.toolName,
+ input: msg.input,
+ description: msg.description,
+ timestamp: Date.now(),
+ }],
+ }))
+ break
+
+ case 'message_complete': {
+ const session = get().sessions[sessionId]
+ if (!session) break
+ const text = session.streamingText
+ if (text) {
+ updateSession((s) => ({
+ messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }],
+ streamingText: '',
+ }))
+ }
+ if (session.elapsedTimer) {
+ clearInterval(session.elapsedTimer)
+ }
+ updateSession(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null }))
+ break
+ }
+
+ case 'error':
+ updateSession((s) => ({
+ messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }],
+ chatState: 'idle',
+ activeThinkingId: null,
+ }))
+ {
+ const session = get().sessions[sessionId]
+ if (session?.elapsedTimer) {
+ clearInterval(session.elapsedTimer)
+ updateSession(() => ({ elapsedTimer: null }))
+ }
+ }
+ break
+
+ case 'team_created':
+ useTeamStore.getState().handleTeamCreated(msg.teamName)
+ break
+ case 'team_update':
+ useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members)
+ break
+ case 'team_deleted':
+ useTeamStore.getState().handleTeamDeleted(msg.teamName)
+ break
+ case 'task_update':
+ break
+ case 'session_title_updated':
+ useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title)
+ break
+ case 'system_notification':
+ if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) {
+ updateSession(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> }))
+ }
+ break
+ case 'pong':
+ break
+ }
+ },
+}))
+
+// ─── History mapping helpers (unchanged) ─────────────────────
+
+type AssistantHistoryBlock = {
+ type: string
+ text?: string
+ thinking?: string
+ name?: string
+ id?: string
+ input?: unknown
+}
+
+type UserHistoryBlock = {
+ type: string
+ text?: string
+ tool_use_id?: string
+ content?: unknown
+ is_error?: boolean
+ source?: { data?: string }
+ mimeType?: string
+ media_type?: string
+ name?: string
+}
+
+export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] {
+ const uiMessages: UIMessage[] = []
+ for (const msg of messages) {
+ const timestamp = new Date(msg.timestamp).getTime()
+ if (msg.type === 'user' && typeof msg.content === 'string') {
+ uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
+ continue
+ }
+ if (msg.type === 'assistant' && typeof msg.content === 'string') {
+ uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model })
+ continue
+ }
+ if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
+ for (const block of msg.content as AssistantHistoryBlock[]) {
+ if (block.type === 'thinking' && block.thinking) {
+ uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
+ } else if (block.type === 'text' && block.text) {
+ uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model })
+ } else if (block.type === 'tool_use') {
+ uiMessages.push({
+ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown',
+ toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId,
+ })
+ }
+ }
+ continue
+ }
+ if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) {
+ const textParts: string[] = []
+ const attachments: UIAttachment[] = []
+ for (const block of msg.content as UserHistoryBlock[]) {
+ if (block.type === 'text' && block.text) {
+ textParts.push(block.text)
+ } else if (block.type === 'image') {
+ attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type })
+ } else if (block.type === 'file') {
+ attachments.push({ type: 'file', name: block.name || 'file' })
+ } else if (block.type === 'tool_result') {
+ uiMessages.push({
+ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '',
+ content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId,
+ })
+ }
+ }
+ if (textParts.length > 0 || attachments.length > 0) {
+ uiMessages.push({
+ id: nextId(), type: 'user_text', content: textParts.join('\n'),
+ attachments: attachments.length > 0 ? attachments : undefined, timestamp,
+ })
+ }
+ }
+ }
+ return uiMessages
+}
+
+function extractLastTodoWriteFromHistory(
+ messages: MessageEntry[],
+): Array<{ content: string; status: string; activeForm?: string }> | null {
+ let foundIndex = -1
+ let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i]!
+ if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
+ const blocks = msg.content as AssistantHistoryBlock[]
+ for (let j = blocks.length - 1; j >= 0; j--) {
+ const block = blocks[j]!
+ if (block.type === 'tool_use' && block.name === 'TodoWrite') {
+ const input = block.input as { todos?: unknown } | undefined
+ if (input && Array.isArray(input.todos)) {
+ todos = input.todos as Array<{ content: string; status: string; activeForm?: string }>
+ foundIndex = i
+ break
+ }
+ }
+ }
+ if (todos) break
+ }
+ }
+ if (!todos) return null
+ const allDone = todos.every((t) => t.status === 'completed')
+ if (allDone) {
+ for (let i = foundIndex + 1; i < messages.length; i++) {
+ const msg = messages[i]!
+ if (msg.type === 'user' && msg.content) return null
+ }
+ }
+ return todos
+}
+
+const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
+
+function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean {
+ let lastTaskIndex = -1
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i]!
+ if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
+ const blocks = msg.content as AssistantHistoryBlock[]
+ if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) {
+ lastTaskIndex = i
+ break
+ }
+ }
+ }
+ if (lastTaskIndex < 0) return false
+ for (let i = lastTaskIndex + 1; i < messages.length; i++) {
+ if (messages[i]!.type === 'user') return true
+ }
+ return false
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/src/stores/chatStore.ts
+git commit -m "refactor: chatStore supports per-session isolated state"
+```
+
+---
+
+### Task 3: Tab Store
+
+**Files:**
+- Create: `desktop/src/stores/tabStore.ts`
+
+- [ ] **Step 1: 创建 tabStore**
+
+```ts
+import { create } from 'zustand'
+import { sessionsApi } from '../api/sessions'
+
+const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
+
+export type Tab = {
+ sessionId: string
+ title: string
+ status: 'idle' | 'running' | 'error'
+}
+
+type TabPersistence = {
+ openTabs: Array<{ sessionId: string; title: string }>
+ activeTabId: string | null
+}
+
+type TabStore = {
+ tabs: Tab[]
+ activeTabId: string | null
+
+ openTab: (sessionId: string, title: string) => void
+ closeTab: (sessionId: string) => void
+ setActiveTab: (sessionId: string) => void
+ updateTabTitle: (sessionId: string, title: string) => void
+ updateTabStatus: (sessionId: string, status: Tab['status']) => void
+
+ saveTabs: () => void
+ restoreTabs: () => Promise
+}
+
+export const useTabStore = create((set, get) => ({
+ tabs: [],
+ activeTabId: null,
+
+ openTab: (sessionId, title) => {
+ const { tabs } = get()
+ const existing = tabs.find((t) => t.sessionId === sessionId)
+ if (existing) {
+ set({ activeTabId: sessionId })
+ } else {
+ set({
+ tabs: [...tabs, { sessionId, title, status: 'idle' }],
+ activeTabId: sessionId,
+ })
+ }
+ get().saveTabs()
+ },
+
+ closeTab: (sessionId) => {
+ const { tabs, activeTabId } = get()
+ const index = tabs.findIndex((t) => t.sessionId === sessionId)
+ if (index < 0) return
+
+ const newTabs = tabs.filter((t) => t.sessionId !== sessionId)
+ let newActiveId = activeTabId
+
+ // If closing the active tab, activate the nearest neighbor
+ if (activeTabId === sessionId) {
+ if (newTabs.length === 0) {
+ newActiveId = null
+ } else if (index >= newTabs.length) {
+ newActiveId = newTabs[newTabs.length - 1]!.sessionId
+ } else {
+ newActiveId = newTabs[index]!.sessionId
+ }
+ }
+
+ set({ tabs: newTabs, activeTabId: newActiveId })
+ get().saveTabs()
+ },
+
+ setActiveTab: (sessionId) => {
+ set({ activeTabId: sessionId })
+ get().saveTabs()
+ },
+
+ updateTabTitle: (sessionId, title) => {
+ set((s) => ({
+ tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, title } : t)),
+ }))
+ get().saveTabs()
+ },
+
+ updateTabStatus: (sessionId, status) => {
+ set((s) => ({
+ tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, status } : t)),
+ }))
+ },
+
+ saveTabs: () => {
+ const { tabs, activeTabId } = get()
+ const data: TabPersistence = {
+ openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title })),
+ activeTabId,
+ }
+ try {
+ localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data))
+ } catch { /* noop */ }
+ },
+
+ restoreTabs: async () => {
+ try {
+ const raw = localStorage.getItem(TAB_STORAGE_KEY)
+ if (!raw) return
+
+ const data = JSON.parse(raw) as TabPersistence
+ if (!data.openTabs || data.openTabs.length === 0) return
+
+ // Validate sessions still exist
+ const { sessions } = await sessionsApi.list({ limit: 200 })
+ const existingIds = new Set(sessions.map((s) => s.id))
+
+ const validTabs: Tab[] = data.openTabs
+ .filter((t) => existingIds.has(t.sessionId))
+ .map((t) => ({
+ sessionId: t.sessionId,
+ title: sessions.find((s) => s.id === t.sessionId)?.title || t.title,
+ status: 'idle' as const,
+ }))
+
+ if (validTabs.length === 0) return
+
+ const activeId = data.activeTabId && validTabs.some((t) => t.sessionId === data.activeTabId)
+ ? data.activeTabId
+ : validTabs[0]!.sessionId
+
+ set({ tabs: validTabs, activeTabId: activeId })
+ } catch { /* noop */ }
+ },
+}))
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/src/stores/tabStore.ts
+git commit -m "feat: add tabStore for multi-tab state management"
+```
+
+---
+
+### Task 4: TabBar 组件
+
+**Files:**
+- Create: `desktop/src/components/layout/TabBar.tsx`
+
+- [ ] **Step 1: 创建 TabBar 组件**
+
+固定宽度 Tab + 滚动箭头 + 关闭按钮 + 状态指示 + 右键上下文菜单。
+
+```tsx
+import { useRef, useState, useEffect, useCallback } from 'react'
+import { useTabStore, type Tab } from '../../stores/tabStore'
+import { useChatStore } from '../../stores/chatStore'
+import { useTranslation } from '../../i18n'
+
+const TAB_WIDTH = 180
+const ARROW_WIDTH = 28
+
+export function TabBar() {
+ const tabs = useTabStore((s) => s.tabs)
+ const activeTabId = useTabStore((s) => s.activeTabId)
+ const setActiveTab = useTabStore((s) => s.setActiveTab)
+ const closeTab = useTabStore((s) => s.closeTab)
+ const disconnectSession = useChatStore((s) => s.disconnectSession)
+
+ const scrollRef = useRef(null)
+ const [canScrollLeft, setCanScrollLeft] = useState(false)
+ const [canScrollRight, setCanScrollRight] = useState(false)
+ const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null)
+ const t = useTranslation()
+
+ const updateScrollState = useCallback(() => {
+ const el = scrollRef.current
+ if (!el) return
+ setCanScrollLeft(el.scrollLeft > 0)
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
+ }, [])
+
+ useEffect(() => {
+ updateScrollState()
+ const el = scrollRef.current
+ if (!el) return
+ el.addEventListener('scroll', updateScrollState)
+ const ro = new ResizeObserver(updateScrollState)
+ ro.observe(el)
+ return () => {
+ el.removeEventListener('scroll', updateScrollState)
+ ro.disconnect()
+ }
+ }, [updateScrollState, tabs.length])
+
+ useEffect(() => {
+ if (!contextMenu) return
+ const close = () => setContextMenu(null)
+ document.addEventListener('click', close)
+ return () => document.removeEventListener('click', close)
+ }, [contextMenu])
+
+ const scroll = (direction: 'left' | 'right') => {
+ const el = scrollRef.current
+ if (!el) return
+ el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' })
+ }
+
+ const handleClose = (sessionId: string) => {
+ disconnectSession(sessionId)
+ closeTab(sessionId)
+ }
+
+ const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
+ e.preventDefault()
+ setContextMenu({ sessionId, x: e.clientX, y: e.clientY })
+ }
+
+ const handleCloseOthers = (sessionId: string) => {
+ setContextMenu(null)
+ const otherIds = tabs.filter((t) => t.sessionId !== sessionId).map((t) => t.sessionId)
+ for (const id of otherIds) handleClose(id)
+ }
+
+ const handleCloseRight = (sessionId: string) => {
+ setContextMenu(null)
+ const idx = tabs.findIndex((t) => t.sessionId === sessionId)
+ const rightIds = tabs.slice(idx + 1).map((t) => t.sessionId)
+ for (const id of rightIds) handleClose(id)
+ }
+
+ if (tabs.length === 0) return null
+
+ return (
+
+ {/* Left arrow */}
+ {canScrollLeft && (
+
+ )}
+
+ {/* Tabs scroll container */}
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.sessionId)}
+ onClose={() => handleClose(tab.sessionId)}
+ onContextMenu={(e) => handleContextMenu(e, tab.sessionId)}
+ />
+ ))}
+
+
+ {/* Right arrow */}
+ {canScrollRight && (
+
+ )}
+
+ {/* Context menu */}
+ {contextMenu && (
+
+
+
+
+
+ )}
+
+ )
+}
+
+function TabItem({ tab, isActive, onClick, onClose, onContextMenu }: {
+ tab: Tab
+ isActive: boolean
+ onClick: () => void
+ onClose: () => void
+ onContextMenu: (e: React.MouseEvent) => void
+}) {
+ return (
+
+ {/* Status indicator */}
+ {tab.status === 'running' && (
+
+ )}
+ {tab.status === 'error' && (
+
+ )}
+
+ {/* Title */}
+
+ {tab.title || 'Untitled'}
+
+
+ {/* Close button */}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/src/components/layout/TabBar.tsx
+git commit -m "feat: add TabBar component with scroll overflow and context menu"
+```
+
+---
+
+### Task 5: i18n 翻译 keys
+
+**Files:**
+- Modify: `desktop/src/i18n/locales/en.ts`
+- Modify: `desktop/src/i18n/locales/zh.ts`
+
+- [ ] **Step 1: 在 en.ts 末尾添加 Tab 相关翻译**
+
+在 `en.ts` 对象末尾(closing `}` 之前)添加:
+
+```ts
+ // ─── Tabs ──────────────────────────────────────
+ 'tabs.close': 'Close',
+ 'tabs.closeOthers': 'Close Others',
+ 'tabs.closeRight': 'Close to the Right',
+ 'tabs.closeConfirmTitle': 'Session Running',
+ 'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
+ 'tabs.closeConfirmKeep': 'Keep Running',
+ 'tabs.closeConfirmStop': 'Stop & Close',
+```
+
+- [ ] **Step 2: 在 zh.ts 末尾添加对应中文翻译**
+
+```ts
+ // ─── Tabs ──────────────────────────────────────
+ 'tabs.close': '关闭',
+ 'tabs.closeOthers': '关闭其他',
+ 'tabs.closeRight': '关闭右侧',
+ 'tabs.closeConfirmTitle': '会话运行中',
+ 'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
+ 'tabs.closeConfirmKeep': '保持运行',
+ 'tabs.closeConfirmStop': '停止并关闭',
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts
+git commit -m "feat: add i18n translations for multi-tab feature"
+```
+
+---
+
+### Task 6: 布局集成 — AppShell + ContentRouter
+
+**Files:**
+- Modify: `desktop/src/components/layout/AppShell.tsx`
+- Modify: `desktop/src/components/layout/ContentRouter.tsx`
+
+- [ ] **Step 1: AppShell 集成 TabBar 和 Tab 恢复**
+
+在 `AppShell.tsx` 中 import TabBar,在 bootstrap 后恢复 Tab 状态,并在 `` 之前渲染 ``。
+
+修改 import 区域,添加:
+```ts
+import { TabBar } from './TabBar'
+import { useTabStore } from '../../stores/tabStore'
+import { useChatStore } from '../../stores/chatStore'
+```
+
+在 `bootstrap` async 函数中,`setReady(true)` 之前添加 Tab 恢复:
+```ts
+ await useTabStore.getState().restoreTabs()
+ // Connect active tab's WS
+ const activeId = useTabStore.getState().activeTabId
+ if (activeId) {
+ useChatStore.getState().connectToSession(activeId)
+ }
+```
+
+修改 main 内容区 JSX,将 `` 替换为:
+```tsx
+
+
+```
+
+- [ ] **Step 2: ContentRouter 从 tabStore 获取 activeTabId**
+
+修改 `ContentRouter.tsx`:
+
+```tsx
+import { useUIStore } from '../../stores/uiStore'
+import { useTabStore } from '../../stores/tabStore'
+import { useTeamStore } from '../../stores/teamStore'
+import { EmptySession } from '../../pages/EmptySession'
+import { ActiveSession } from '../../pages/ActiveSession'
+import { ScheduledTasks } from '../../pages/ScheduledTasks'
+import { Settings } from '../../pages/Settings'
+import { AgentTranscript } from '../../pages/AgentTranscript'
+
+export function ContentRouter() {
+ const activeView = useUIStore((s) => s.activeView)
+ const activeTabId = useTabStore((s) => s.activeTabId)
+ const viewingAgentId = useTeamStore((s) => s.viewingAgentId)
+
+ if (activeView === 'settings') {
+ return
+ }
+
+ if (activeView === 'scheduled') {
+ return
+ }
+
+ if (activeView === 'terminal') {
+ return
+ }
+
+ if (activeView === 'history') {
+ if (viewingAgentId) {
+ return
+ }
+ return
+ }
+
+ // Code view
+ if (!activeTabId) {
+ return
+ }
+
+ if (viewingAgentId) {
+ return
+ }
+
+ return
+}
+```
+
+(TerminalPlaceholder 和 HistoryPlaceholder 函数保持不变)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/src/components/layout/AppShell.tsx desktop/src/components/layout/ContentRouter.tsx
+git commit -m "feat: integrate TabBar into AppShell layout and route by activeTabId"
+```
+
+---
+
+### Task 7: ActiveSession 适配多 Tab
+
+**Files:**
+- Modify: `desktop/src/pages/ActiveSession.tsx`
+
+- [ ] **Step 1: 改为从 tabStore 和 chatStore.sessions 读取**
+
+```tsx
+import { useEffect, useMemo } from 'react'
+import { useTabStore } from '../stores/tabStore'
+import { useSessionStore } from '../stores/sessionStore'
+import { useChatStore } from '../stores/chatStore'
+import { MessageList } from '../components/chat/MessageList'
+import { ChatInput } from '../components/chat/ChatInput'
+import { TeamStatusBar } from '../components/teams/TeamStatusBar'
+import { SessionTaskBar } from '../components/chat/SessionTaskBar'
+
+export function ActiveSession() {
+ const activeTabId = useTabStore((s) => s.activeTabId)
+ const sessions = useSessionStore((s) => s.sessions)
+ const connectToSession = useChatStore((s) => s.connectToSession)
+ const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
+
+ const session = sessions.find((s) => s.id === activeTabId)
+ const chatState = sessionState?.chatState ?? 'idle'
+ const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
+
+ useEffect(() => {
+ if (activeTabId) {
+ connectToSession(activeTabId)
+ }
+ }, [activeTabId, connectToSession])
+
+ const isActive = chatState !== 'idle'
+ const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
+
+ const lastUpdated = useMemo(() => {
+ if (!session?.modifiedAt) return ''
+ const diff = Date.now() - new Date(session.modifiedAt).getTime()
+ if (diff < 60000) return 'just now'
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
+ return `${Math.floor(diff / 86400000)}d ago`
+ }, [session?.modifiedAt])
+
+ if (!activeTabId) return null
+
+ return (
+
+ {/* Session info header */}
+
+
+
+ {session?.title || 'Untitled Session'}
+
+
+ {isActive && (
+
+
+ session active
+
+ )}
+ {totalTokens > 0 && (
+ <>
+ ·
+ {totalTokens.toLocaleString()} t
+ >
+ )}
+ {lastUpdated && (
+ <>
+ ·
+ last updated {lastUpdated}
+ >
+ )}
+ {session?.messageCount !== undefined && session.messageCount > 0 && (
+ <>
+ ·
+ {session.messageCount} messages
+ >
+ )}
+
+ {session?.workDirExists === false && (
+
+ warning
+
+ Workspace unavailable: {session.workDir || 'directory no longer exists'}
+
+
+ )}
+
+
+
+ {/* Message stream */}
+
+
+ {/* Session task bar — sticky at bottom */}
+
+
+ {/* Team status bar */}
+
+
+ {/* Chat input */}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/src/pages/ActiveSession.tsx
+git commit -m "feat: ActiveSession reads from tabStore and per-session chatStore"
+```
+
+---
+
+### Task 8: Sidebar 集成 — 点击打开 Tab
+
+**Files:**
+- Modify: `desktop/src/components/layout/Sidebar.tsx`
+
+- [ ] **Step 1: 修改 Sidebar session 点击行为**
+
+在 Sidebar.tsx 中 import tabStore 和 chatStore:
+```ts
+import { useTabStore } from '../../stores/tabStore'
+import { useChatStore } from '../../stores/chatStore'
+```
+
+修改 session 按钮的 `onClick`,从:
+```ts
+onClick={() => { setActiveView('code'); setActiveSession(session.id) }}
+```
+改为:
+```ts
+onClick={() => {
+ setActiveView('code')
+ const { openTab } = useTabStore.getState()
+ openTab(session.id, session.title)
+ useChatStore.getState().connectToSession(session.id)
+}}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/src/components/layout/Sidebar.tsx
+git commit -m "feat: sidebar click opens session in tab instead of replacing"
+```
+
+---
+
+### Task 9: EmptySession 集成 — 新建 Session 自动开 Tab
+
+**Files:**
+- Modify: `desktop/src/pages/EmptySession.tsx`
+
+- [ ] **Step 1: 修改 handleSubmit 创建 Session 后打开 Tab**
+
+在 EmptySession.tsx 中添加 import:
+```ts
+import { useTabStore } from '../stores/tabStore'
+```
+
+在 `handleSubmit` 中,`createSession` 成功后,添加 openTab 调用。修改 `try` 块:
+```ts
+ const sessionId = await createSession(workDir || undefined)
+ setActiveView('code')
+ // Open as tab
+ useTabStore.getState().openTab(sessionId, 'New Session')
+ connectToSession(sessionId)
+ const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
+ type: attachment.type,
+ name: attachment.name,
+ data: attachment.data,
+ mimeType: attachment.mimeType,
+ }))
+ sendMessage(text, attachmentPayload)
+ setInput('')
+ setAttachments([])
+```
+
+注意:这里 `sendMessage` 现在需要传 sessionId 作为第一个参数:
+```ts
+ sendMessage(sessionId, text, attachmentPayload)
+```
+
+同时更新 sendMessage 的引用获取:
+```ts
+const sendMessage = useChatStore((state) => state.sendMessage)
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/src/pages/EmptySession.tsx
+git commit -m "feat: new session creation automatically opens as tab"
+```
+
+---
+
+### Task 10: MessageList 和 ChatInput 适配 per-session 数据
+
+**Files:**
+- Modify: `desktop/src/components/chat/MessageList.tsx`
+- Modify: `desktop/src/components/chat/ChatInput.tsx`
+
+- [ ] **Step 1: 修改 MessageList 从 per-session state 读取**
+
+在 MessageList.tsx 中,找到从 `useChatStore` 读取 `messages`, `chatState`, `streamingText` 等的地方,改为通过 `activeTabId` 从 `sessions[activeTabId]` 读取。
+
+添加 import:
+```ts
+import { useTabStore } from '../../stores/tabStore'
+```
+
+将所有从 chatStore 直接读取的字段改为通过 helper:
+```ts
+const activeTabId = useTabStore((s) => s.activeTabId)
+const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
+const messages = sessionState?.messages ?? []
+const chatState = sessionState?.chatState ?? 'idle'
+const streamingText = sessionState?.streamingText ?? ''
+// ... 等等
+```
+
+- [ ] **Step 2: 修改 ChatInput 发送消息时传 sessionId**
+
+在 ChatInput.tsx 中,找到调用 `sendMessage(content, attachments)` 的地方,改为 `sendMessage(activeTabId, content, attachments)`。
+
+同样对 `respondToPermission`, `stopGeneration`, `setSessionPermissionMode` 等调用添加 sessionId 参数。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/src/components/chat/MessageList.tsx desktop/src/components/chat/ChatInput.tsx
+git commit -m "feat: MessageList and ChatInput read from per-session state"
+```
+
+---
+
+### Task 11: Tab 状态同步 — session_title_updated + chatState
+
+**Files:**
+- Modify: `desktop/src/stores/chatStore.ts`
+
+- [ ] **Step 1: 在 handleServerMessage 中同步 Tab 状态**
+
+在 `session_title_updated` case 中添加:
+```ts
+ case 'session_title_updated':
+ useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title)
+ // Sync to tab title
+ import('../stores/tabStore').then(({ useTabStore }) => {
+ useTabStore.getState().updateTabTitle(msg.sessionId, msg.title)
+ })
+ break
+```
+
+更简洁的方式:在文件顶部 import tabStore:
+```ts
+import { useTabStore } from './tabStore'
+```
+
+然后在 `status` case 中同步 tab status:
+```ts
+ case 'status':
+ updateSession((session) => ({ ... }))
+ // Sync tab status
+ if (msg.state === 'idle') {
+ useTabStore.getState().updateTabStatus(sessionId, 'idle')
+ } else {
+ useTabStore.getState().updateTabStatus(sessionId, 'running')
+ }
+ break
+```
+
+在 `error` case 中:
+```ts
+ useTabStore.getState().updateTabStatus(sessionId, 'error')
+```
+
+在 `session_title_updated` case 中:
+```ts
+ useTabStore.getState().updateTabTitle(msg.sessionId, msg.title)
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/src/stores/chatStore.ts
+git commit -m "feat: sync tab status and title from server messages"
+```
+
+---
+
+### Task 12: Tab 关闭确认对话框
+
+**Files:**
+- Create: `desktop/src/components/layout/TabCloseDialog.tsx`
+- Modify: `desktop/src/components/layout/TabBar.tsx`
+
+- [ ] **Step 1: 创建 TabCloseDialog 组件**
+
+```tsx
+import { Modal } from '../shared/Modal'
+import { Button } from '../shared/Button'
+import { useTranslation } from '../../i18n'
+
+type Props = {
+ open: boolean
+ onKeepRunning: () => void
+ onStopAndClose: () => void
+ onCancel: () => void
+}
+
+export function TabCloseDialog({ open, onKeepRunning, onStopAndClose, onCancel }: Props) {
+ const t = useTranslation()
+
+ return (
+
+
+
+
+ >
+ }>
+
+ {t('tabs.closeConfirmMessage')}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: 在 TabBar 中集成关闭确认逻辑**
+
+在 TabBar.tsx 中 import 对话框:
+```ts
+import { TabCloseDialog } from './TabCloseDialog'
+```
+
+添加 state:
+```ts
+ const [closingTabId, setClosingTabId] = useState(null)
+```
+
+修改 `handleClose` 函数,检查 session 是否正在运行:
+```ts
+ const handleClose = (sessionId: string) => {
+ const sessionState = useChatStore.getState().sessions[sessionId]
+ const isRunning = sessionState && sessionState.chatState !== 'idle'
+
+ if (isRunning) {
+ // TODO: check settings for tabCloseWithRunningSession
+ setClosingTabId(sessionId)
+ return
+ }
+
+ disconnectSession(sessionId)
+ closeTab(sessionId)
+ }
+```
+
+在 JSX 末尾(context menu 后面)添加对话框:
+```tsx
+ {closingTabId && (
+ {
+ closeTab(closingTabId)
+ setClosingTabId(null)
+ }}
+ onStopAndClose={() => {
+ useChatStore.getState().stopGeneration(closingTabId)
+ disconnectSession(closingTabId)
+ closeTab(closingTabId)
+ setClosingTabId(null)
+ }}
+ onCancel={() => setClosingTabId(null)}
+ />
+ )}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/src/components/layout/TabCloseDialog.tsx desktop/src/components/layout/TabBar.tsx
+git commit -m "feat: add confirmation dialog when closing running session tab"
+```
+
+---
+
+### Task 13: 编译验证和集成测试
+
+**Files:**
+- All modified files
+
+- [ ] **Step 1: TypeScript 编译检查**
+
+Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx tsc --noEmit`
+Expected: 零错误。如有错误,逐一修复类型问题。
+
+- [ ] **Step 2: Vite 开发服务器启动测试**
+
+Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx vite build 2>&1 | tail -20`
+Expected: Build 成功,无错误。
+
+- [ ] **Step 3: 运行现有测试**
+
+Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop && npx vitest run 2>&1`
+Expected: 测试通过(可能需要更新 mock)。
+
+- [ ] **Step 4: 修复所有发现的问题**
+
+根据编译/构建/测试结果修复问题。
+
+- [ ] **Step 5: Final commit**
+
+```bash
+git add -A
+git commit -m "fix: resolve compilation and integration issues for multi-tab"
+```
diff --git a/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md b/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md
new file mode 100644
index 00000000..9bd3c908
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md
@@ -0,0 +1,221 @@
+# Multi-Tab Sessions Design Spec
+
+## Overview
+
+Desktop webapp (Tauri + React + Zustand) 新增多 Tab 功能,允许用户同时打开多个 Session 并行工作。类似 VS Code 的编辑器 Tab 与文件浏览器的关系:Sidebar 浏览所有 Session,Tab 栏管理当前打开的 Session。
+
+## Tech Stack
+
+- **Desktop App**: Tauri v2 + React 18 + Zustand 5 + Tailwind CSS 4 + Vite
+- **Backend Server**: `src/server/` — 已支持多 Session 并发 (`maxSessions` 配置)
+- **通信协议**: 每个 Session 独立 WebSocket (`/ws/${sessionId}`) + REST API
+
+## Key Design Decisions
+
+| 决策 | 选择 | 理由 |
+|------|------|------|
+| Tab 与 Sidebar 关系 | 独立共存 (VS Code 模式) | Sidebar 浏览全部历史, Tab 管理当前打开的 |
+| Tab 栏位置 | 内容区域顶部 (Sidebar 右侧上方) | 不影响 Sidebar 布局 |
+| 并发策略 | 每个 Tab 独立 WebSocket 连接 | 真正并行, 后端已支持, WS 连接轻量 |
+| Tab 数量限制 | 不限制 | 用户自由管理 |
+| Tab 溢出处理 | 固定宽度 + 左右滚动箭头 | VS Code 风格 |
+| 关闭运行中 Tab | 可配置 (默认弹确认框) | 配置项: ask / background / stop |
+| Tab 持久化 | 可配置 (默认恢复) | 存 localStorage, 启动时恢复 |
+| Sidebar 打开行为 | 去重 + 聚焦 | 已打开则跳转, 否则新 Tab |
+| 新建 Session 入口 | 仅 Sidebar | 保持单一入口 |
+
+## Architecture Changes
+
+### 1. New Tab Store (`tabStore.ts`)
+
+新增 Zustand store 管理 Tab 状态:
+
+```ts
+interface Tab {
+ sessionId: string
+ title: string
+ status: 'idle' | 'running' | 'error'
+}
+
+interface TabStore {
+ tabs: Tab[]
+ activeTabId: string | null // sessionId
+
+ openTab(sessionId: string, title: string): void
+ closeTab(sessionId: string): void
+ setActiveTab(sessionId: string): void
+ updateTabTitle(sessionId: string, title: string): void
+ updateTabStatus(sessionId: string, status: Tab['status']): void
+ reorderTabs(fromIndex: number, toIndex: number): void
+
+ // Persistence
+ saveTabs(): void
+ restoreTabs(): Tab[]
+}
+```
+
+### 2. chatStore Refactor
+
+从单 Session 改为多 Session 并发:
+
+**Before**: 单一 `messages[]` + 单一 `connectedSessionId`
+**After**: 按 sessionId 隔离的 session map
+
+```ts
+interface PerSessionState {
+ messages: UIMessage[]
+ connectionState: 'connecting' | 'connected' | 'disconnected'
+ streamingState: StreamingState
+ permissionRequests: PermissionRequest[]
+ tasks: TaskState[]
+}
+
+interface ChatStore {
+ sessions: Record
+ connectToSession(sessionId: string): void
+ disconnectSession(sessionId: string): void
+ sendMessage(sessionId: string, content: string, attachments?: AttachmentRef[]): void
+ // ... per-session methods all accept sessionId
+}
+```
+
+### 3. WebSocket Manager Refactor
+
+从单例连接改为多连接管理:
+
+**Before**: 全局一个 WS 连接
+**After**: 按 sessionId 管理多个独立 WS 连接
+
+```ts
+class WebSocketManager {
+ connections: Map
+ connect(sessionId: string): void
+ disconnect(sessionId: string): void
+ disconnectAll(): void
+ getConnection(sessionId: string): WebSocketConnection | undefined
+ send(sessionId: string, message: ClientMessage): void
+}
+```
+
+### 4. UI Components
+
+#### 4.1 TabBar Component (New)
+
+位置: 内容区域顶部, Sidebar 右侧上方。
+
+- 固定 Tab 宽度, 显示 Session 标题 (截断) + 状态指示 + 关闭按钮
+- 超出容器宽度时显示左右滚动箭头
+- 当前活跃 Tab 高亮
+- Tab 运行中显示状态指示 (spinner/dot)
+- 右键上下文菜单: 关闭、关闭其他、关闭右侧
+
+#### 4.2 Layout Changes (AppShell / ContentRouter)
+
+```
+Before:
+┌────────────────────────────────────────┐
+│ Tauri Drag Region │
+├──────────┬─────────────────────────────┤
+│ Sidebar │ ContentRouter │
+│ │ (single active session) │
+└──────────┴─────────────────────────────┘
+
+After:
+┌────────────────────────────────────────┐
+│ Tauri Drag Region │
+├──────────┬─────────────────────────────┤
+│ Sidebar │ TabBar │
+│ ├─────────────────────────────┤
+│ │ ContentRouter │
+│ │ (active tab's session) │
+└──────────┴─────────────────────────────┘
+```
+
+#### 4.3 ActiveSession Changes
+
+当前 ActiveSession 组件从 `sessionStore.activeSessionId` 获取 session。
+改为从 `tabStore.activeTabId` 获取, 并从 `chatStore.sessions[activeTabId]` 读取消息。
+
+### 5. Settings Additions
+
+新增两个配置项到 Settings 页面:
+
+- **关闭运行中 Tab 的行为**: `ask` | `background` | `stop` (默认 `ask`)
+- **启动时恢复 Tab**: `boolean` (默认 `true`)
+
+### 6. Sidebar Integration
+
+修改 Sidebar 点击行为:
+- 点击 Session → 检查 tabStore 是否已打开该 Session
+ - 已打开: `setActiveTab(sessionId)` 聚焦
+ - 未打开: `openTab(sessionId, title)` 创建新 Tab + 连接 WS
+- 新建 Session → 创建后自动 `openTab`
+
+### 7. Tab Close Flow
+
+```
+User clicks X on tab
+ → Is session running?
+ → No: close tab, disconnect WS, cleanup chatStore session state
+ → Yes: check setting `tabCloseWithRunningSession`
+ → 'ask': show confirm dialog (3 options: keep running / stop / cancel)
+ → 'background': close tab, keep WS + session running
+ → 'stop': send stop_generation, close tab, disconnect WS
+```
+
+### 8. Tab Persistence
+
+存储到 `localStorage`:
+```ts
+{
+ openTabs: Array<{ sessionId: string; title: string }>
+ activeTabId: string | null
+}
+```
+
+启动时:
+1. 读取 localStorage
+2. 检查 `restoreTabsOnStartup` 配置
+3. 校验每个 sessionId 是否仍存在 (API check)
+4. 恢复有效 Tab, 连接活跃 Tab 的 WS
+
+## Backend Impact
+
+**无需后端改动**。后端已支持:
+- 多 Session 并发 (`maxSessions` 配置)
+- 独立 WebSocket 端点 (`/ws/${sessionId}`)
+- Session CRUD REST API
+- 独立的消息历史存储
+
+前端并行打开多个 WS 连接即可。
+
+## Files to Modify
+
+### New Files
+- `desktop/src/stores/tabStore.ts`
+- `desktop/src/components/layout/TabBar.tsx`
+- `desktop/src/components/layout/TabCloseDialog.tsx`
+
+### Modified Files
+- `desktop/src/stores/chatStore.ts` — 多 session 状态隔离
+- `desktop/src/api/websocket.ts` — 多连接管理
+- `desktop/src/components/layout/AppShell.tsx` — 集成 TabBar
+- `desktop/src/components/layout/ContentRouter.tsx` — 从 tabStore 获取 activeTabId
+- `desktop/src/components/layout/Sidebar.tsx` — 点击行为改为 openTab
+- `desktop/src/pages/ActiveSession.tsx` — 从 chatStore.sessions[id] 读取
+- `desktop/src/pages/Settings.tsx` — 新增 Tab 相关设置项
+- `desktop/src/stores/uiStore.ts` — 可能需要调整 activeView 逻辑
+- `desktop/src/stores/cliTaskStore.ts` — 按 sessionId 隔离 task 状态
+
+## Performance Considerations
+
+- 每个 WS 连接占用极少资源, 10 个以内无需担心
+- 消息缓存在内存中, 极端情况 (大量 Tab + 长对话) 可能占用较多内存
+- 非活跃 Tab 不渲染 DOM (React 条件渲染), 无 UI 性能开销
+- Tab 切换无需重新加载消息 (已缓存), 切换体验流畅
+
+## Testing Strategy
+
+- Unit tests: tabStore CRUD, persistence, close flow logic
+- Integration tests: Sidebar → Tab → WS 连接全链路
+- Edge cases: 关闭最后一个 Tab、恢复已删除的 Session、大量 Tab 滚动