cc-haha/docs/superpowers/plans/2026-04-09-multi-tab-sessions.md
程序员阿江(Relakkes) c056b3c4fd docs: add multi-tab sessions implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:28:27 +08:00

56 KiB
Raw Blame History

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, connection> 模式。每个方法都以 sessionId 为 key。

import type { ClientMessage, ServerMessage } from '../types/chat'
import { getBaseUrl } from './client'

type MessageHandler = (msg: ServerMessage) => void

type Connection = {
  ws: WebSocket
  handlers: Set<MessageHandler>
  reconnectTimer: ReturnType<typeof setTimeout> | null
  reconnectAttempt: number
  pingInterval: ReturnType<typeof setInterval> | null
  intentionalClose: boolean
  pendingMessages: ClientMessage[]
}

class WebSocketManager {
  private connections = new Map<string, Connection>()

  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
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 结构。

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<typeof setInterval> | 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<string, PerSessionState>

  // 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<void>
  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<string>()

let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`

export const useChatStore = create<ChatStore>((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<PerSessionState>) => {
      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
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

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<void>
}

export const useTabStore = create<TabStore>((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
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 + 滚动箭头 + 关闭按钮 + 状态指示 + 右键上下文菜单。

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<HTMLDivElement>(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 (
    <div className="flex items-center border-b border-[var(--color-border)] bg-[var(--color-surface)] min-h-[36px] select-none">
      {/* Left arrow */}
      {canScrollLeft && (
        <button onClick={() => scroll('left')} className="flex-shrink-0 w-7 h-full flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]">
          <span className="material-symbols-outlined text-[16px]">chevron_left</span>
        </button>
      )}

      {/* Tabs scroll container */}
      <div ref={scrollRef} className="flex-1 flex overflow-x-hidden">
        {tabs.map((tab) => (
          <TabItem
            key={tab.sessionId}
            tab={tab}
            isActive={tab.sessionId === activeTabId}
            onClick={() => setActiveTab(tab.sessionId)}
            onClose={() => handleClose(tab.sessionId)}
            onContextMenu={(e) => handleContextMenu(e, tab.sessionId)}
          />
        ))}
      </div>

      {/* Right arrow */}
      {canScrollRight && (
        <button onClick={() => scroll('right')} className="flex-shrink-0 w-7 h-full flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]">
          <span className="material-symbols-outlined text-[16px]">chevron_right</span>
        </button>
      )}

      {/* Context menu */}
      {contextMenu && (
        <div
          className="fixed z-50 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1 min-w-[160px]"
          style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
        >
          <button
            onClick={() => { handleClose(contextMenu.sessionId); setContextMenu(null) }}
            className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
          >
            {t('tabs.close')}
          </button>
          <button
            onClick={() => handleCloseOthers(contextMenu.sessionId)}
            className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
          >
            {t('tabs.closeOthers')}
          </button>
          <button
            onClick={() => handleCloseRight(contextMenu.sessionId)}
            className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
          >
            {t('tabs.closeRight')}
          </button>
        </div>
      )}
    </div>
  )
}

function TabItem({ tab, isActive, onClick, onClose, onContextMenu }: {
  tab: Tab
  isActive: boolean
  onClick: () => void
  onClose: () => void
  onContextMenu: (e: React.MouseEvent) => void
}) {
  return (
    <div
      onClick={onClick}
      onContextMenu={onContextMenu}
      className={`
        flex-shrink-0 flex items-center gap-1.5 px-3 h-[36px] border-r border-[var(--color-border)] cursor-pointer group transition-colors
        ${isActive
          ? 'bg-[var(--color-surface)] border-b-2 border-b-[var(--color-brand)]'
          : 'bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)]'
        }
      `}
      style={{ width: TAB_WIDTH, maxWidth: TAB_WIDTH }}
    >
      {/* Status indicator */}
      {tab.status === 'running' && (
        <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse flex-shrink-0" />
      )}
      {tab.status === 'error' && (
        <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-error)] flex-shrink-0" />
      )}

      {/* Title */}
      <span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
        {tab.title || 'Untitled'}
      </span>

      {/* Close button */}
      <button
        onClick={(e) => { e.stopPropagation(); onClose() }}
        className="flex-shrink-0 w-4 h-4 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-[var(--color-surface-hover)] transition-opacity text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
      >
        <span className="material-symbols-outlined text-[14px]">close</span>
      </button>
    </div>
  )
}
  • Step 2: Commit
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 } 之前)添加:

  // ─── 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 末尾添加对应中文翻译
  // ─── Tabs ──────────────────────────────────────
  'tabs.close': '关闭',
  'tabs.closeOthers': '关闭其他',
  'tabs.closeRight': '关闭右侧',
  'tabs.closeConfirmTitle': '会话运行中',
  'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
  'tabs.closeConfirmKeep': '保持运行',
  'tabs.closeConfirmStop': '停止并关闭',
  • Step 3: Commit
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 状态,并在 <ContentRouter /> 之前渲染 <TabBar />

修改 import 区域,添加:

import { TabBar } from './TabBar'
import { useTabStore } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'

bootstrap async 函数中,setReady(true) 之前添加 Tab 恢复:

        await useTabStore.getState().restoreTabs()
        // Connect active tab's WS
        const activeId = useTabStore.getState().activeTabId
        if (activeId) {
          useChatStore.getState().connectToSession(activeId)
        }

修改 main 内容区 JSX<ContentRouter /> 替换为:

        <TabBar />
        <ContentRouter />
  • Step 2: ContentRouter 从 tabStore 获取 activeTabId

修改 ContentRouter.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 <Settings />
  }

  if (activeView === 'scheduled') {
    return <ScheduledTasks />
  }

  if (activeView === 'terminal') {
    return <TerminalPlaceholder />
  }

  if (activeView === 'history') {
    if (viewingAgentId) {
      return <AgentTranscript />
    }
    return <HistoryPlaceholder />
  }

  // Code view
  if (!activeTabId) {
    return <EmptySession />
  }

  if (viewingAgentId) {
    return <AgentTranscript />
  }

  return <ActiveSession />
}

TerminalPlaceholder 和 HistoryPlaceholder 函数保持不变)

  • Step 3: Commit
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 读取

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 (
    <div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
      {/* Session info header */}
      <div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
        <div className="flex-1">
          <h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
            {session?.title || 'Untitled Session'}
          </h1>
          <div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
            {isActive && (
              <span className="flex items-center gap-1">
                <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
                session active
              </span>
            )}
            {totalTokens > 0 && (
              <>
                <span className="text-[var(--color-outline)]">·</span>
                <span>{totalTokens.toLocaleString()} t</span>
              </>
            )}
            {lastUpdated && (
              <>
                <span className="text-[var(--color-outline)]">·</span>
                <span>last updated {lastUpdated}</span>
              </>
            )}
            {session?.messageCount !== undefined && session.messageCount > 0 && (
              <>
                <span className="text-[var(--color-outline)]">·</span>
                <span>{session.messageCount} messages</span>
              </>
            )}
          </div>
          {session?.workDirExists === false && (
            <div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
              <span className="material-symbols-outlined text-[14px]">warning</span>
              <span className="truncate">
                Workspace unavailable: {session.workDir || 'directory no longer exists'}
              </span>
            </div>
          )}
        </div>
      </div>

      {/* Message stream */}
      <MessageList />

      {/* Session task bar — sticky at bottom */}
      <SessionTaskBar />

      {/* Team status bar */}
      <TeamStatusBar />

      {/* Chat input */}
      <ChatInput />
    </div>
  )
}
  • Step 2: Commit
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

import { useTabStore } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'

修改 session 按钮的 onClick,从:

onClick={() => { setActiveView('code'); setActiveSession(session.id) }}

改为:

onClick={() => {
  setActiveView('code')
  const { openTab } = useTabStore.getState()
  openTab(session.id, session.title)
  useChatStore.getState().connectToSession(session.id)
}}
  • Step 2: Commit
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

import { useTabStore } from '../stores/tabStore'

handleSubmit 中,createSession 成功后,添加 openTab 调用。修改 try 块:

      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 作为第一个参数:

      sendMessage(sessionId, text, attachmentPayload)

同时更新 sendMessage 的引用获取:

const sendMessage = useChatStore((state) => state.sendMessage)
  • Step 2: Commit
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 等的地方,改为通过 activeTabIdsessions[activeTabId] 读取。

添加 import

import { useTabStore } from '../../stores/tabStore'

将所有从 chatStore 直接读取的字段改为通过 helper

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
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 中添加:

      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

import { useTabStore } from './tabStore'

然后在 status case 中同步 tab status

      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 中:

        useTabStore.getState().updateTabStatus(sessionId, 'error')

session_title_updated case 中:

        useTabStore.getState().updateTabTitle(msg.sessionId, msg.title)
  • Step 2: Commit
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 组件

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 (
    <Modal open={open} onClose={onCancel} title={t('tabs.closeConfirmTitle')} width={400} footer={
      <>
        <Button variant="secondary" onClick={onCancel}>{t('common.cancel')}</Button>
        <Button variant="secondary" onClick={onKeepRunning}>{t('tabs.closeConfirmKeep')}</Button>
        <Button onClick={onStopAndClose}>{t('tabs.closeConfirmStop')}</Button>
      </>
    }>
      <p className="text-sm text-[var(--color-text-secondary)]">
        {t('tabs.closeConfirmMessage')}
      </p>
    </Modal>
  )
}
  • Step 2: 在 TabBar 中集成关闭确认逻辑

在 TabBar.tsx 中 import 对话框:

import { TabCloseDialog } from './TabCloseDialog'

添加 state

  const [closingTabId, setClosingTabId] = useState<string | null>(null)

修改 handleClose 函数,检查 session 是否正在运行:

  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 后面)添加对话框:

      {closingTabId && (
        <TabCloseDialog
          open={true}
          onKeepRunning={() => {
            closeTab(closingTabId)
            setClosingTabId(null)
          }}
          onStopAndClose={() => {
            useChatStore.getState().stopGeneration(closingTabId)
            disconnectSession(closingTabId)
            closeTab(closingTabId)
            setClosingTabId(null)
          }}
          onCancel={() => setClosingTabId(null)}
        />
      )}
  • Step 3: Commit
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
git add -A
git commit -m "fix: resolve compilation and integration issues for multi-tab"