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" +```