feat: add multi-tab sessions for desktop app

Support multiple concurrent sessions in the desktop webapp with VS Code-style tabs.
Each tab maintains its own independent WebSocket connection and message state.

Key changes:
- WebSocket manager: singleton → multi-connection Map
- chatStore: flat state → per-session isolated state
- New tabStore with localStorage persistence
- TabBar component with scroll overflow and context menu
- Sidebar/EmptySession integrated with tab system

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 00:48:04 +08:00
commit db19491fc8
26 changed files with 4236 additions and 520 deletions

1266
desktop/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript": "^5.9.3",
"vite": "^6.0.7",
"vitest": "^3.0.4"
},

View File

@ -12,8 +12,10 @@ import { ToolInspection } from '../pages/ToolInspection'
// Layout components (chrome is now here, not in pages)
import { AppShell } from '../components/layout/AppShell'
import { Sidebar } from '../components/layout/Sidebar'
import { UserMessage } from '../components/chat/UserMessage'
import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore'
/**
* Core rendering tests: content-only pages must render without crashing
@ -35,21 +37,66 @@ describe('Content-only pages render without errors', () => {
})
it('ActiveSession renders with chat components', () => {
const SESSION_ID = 'test-active-session'
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', status: 'idle' }], activeTabId: SESSION_ID })
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
elapsedTimer: null,
},
},
})
const { container } = render(<ActiveSession />)
// Should have the title area and chat input
expect(container.innerHTML).toContain('Untitled Session')
// ChatInput has a textarea
expect(container.querySelector('textarea')).toBeInTheDocument()
expect(container.innerHTML).not.toContain('Preview')
// Cleanup
useTabStore.setState({ tabs: [], activeTabId: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession shows a single primary action button while a turn is active', () => {
useChatStore.setState({ chatState: 'thinking' })
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] })
useChatStore.setState({
sessions: {
'active-tab': {
messages: [],
chatState: 'thinking',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument()
useChatStore.setState({ chatState: 'idle' })
useChatStore.setState({ sessions: {} })
})
it('AgentTeams renders team strip and members', () => {
@ -102,11 +149,11 @@ describe('Chat attachments', () => {
describe('AppShell layout renders chrome', () => {
it('AppShell renders sidebar and session shell', () => {
const { container } = render(<AppShell />)
const { container } = render(<Sidebar />)
expect(container.querySelector('aside')).toBeInTheDocument()
expect(container.innerHTML).toContain('New session')
expect(container.innerHTML).toContain('Scheduled')
expect(container.innerHTML).toContain('Select a project')
expect(container.innerHTML).toContain('All projects')
})
})

View File

@ -1,5 +1,6 @@
import { useState } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
@ -47,6 +48,7 @@ function parseInput(input: unknown): Question[] {
export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
const { sendMessage } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const t = useTranslation()
const questions = parseInput(input)
const [activeTab, setActiveTab] = useState(0)
@ -82,7 +84,8 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
if (!response) return
setSubmitted(true)
sendMessage(response)
if (!activeTabId) return
sendMessage(activeTabId, response)
}
// All questions must be answered (via selection or free text) to enable submit

View File

@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from '../../i18n'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { sessionsApi } from '../../api/sessions'
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
@ -43,7 +44,11 @@ export function ChatInput() {
const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const { sendMessage, chatState, stopGeneration, slashCommands } = useChatStore()
const { sendMessage, stopGeneration } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const slashCommands = sessionState?.slashCommands ?? []
const activeSessionId = useSessionStore((state) => state.activeSessionId)
const activeSession = useSessionStore((state) => state.sessions.find((session) => session.id === state.activeSessionId) ?? null)
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
@ -213,7 +218,7 @@ export function ChatInput() {
mimeType: attachment.mimeType,
}))
sendMessage(text, attachmentPayload)
sendMessage(activeTabId!, text, attachmentPayload)
setInput('')
setAttachments([])
setSlashMenuOpen(false)
@ -494,7 +499,7 @@ export function ChatInput() {
<div className="flex items-center gap-2">
<ModelSelector />
<button
onClick={isActive ? stopGeneration : handleSubmit}
onClick={isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isActive && !canSubmit}
title={isActive ? t('chat.stopTitle') : undefined}
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30 ${

View File

@ -2,53 +2,72 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MessageList, buildRenderItems } from './MessageList'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import type { UIMessage } from '../../types/chat'
import type { PerSessionState } from '../../stores/chatStore'
const ACTIVE_TAB = 'active-tab'
function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionState {
return {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
elapsedTimer: null,
...overrides,
}
}
describe('MessageList nested tool calls', () => {
beforeEach(() => {
useChatStore.setState({
messages: [],
chatState: 'idle',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
elapsedSeconds: 0,
})
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', status: 'idle' }] })
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
})
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
useChatStore.setState({
messages: [
{
id: 'tool-agent',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-1',
input: { description: 'Inspect src/components' },
timestamp: 1,
},
{
id: 'tool-read',
type: 'tool_use',
toolName: 'Read',
toolUseId: 'read-1',
input: { file_path: '/tmp/example.ts' },
timestamp: 2,
parentToolUseId: 'agent-1',
},
{
id: 'result-read',
type: 'tool_result',
toolUseId: 'read-1',
content: 'const answer = 42',
isError: false,
timestamp: 3,
parentToolUseId: 'agent-1',
},
],
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'tool-agent',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-1',
input: { description: 'Inspect src/components' },
timestamp: 1,
},
{
id: 'tool-read',
type: 'tool_use',
toolName: 'Read',
toolUseId: 'read-1',
input: { file_path: '/tmp/example.ts' },
timestamp: 2,
parentToolUseId: 'agent-1',
},
{
id: 'result-read',
type: 'tool_result',
toolUseId: 'read-1',
content: 'const answer = 42',
isError: false,
timestamp: 3,
parentToolUseId: 'agent-1',
},
],
}),
},
})
const { container } = render(<MessageList />)
@ -106,24 +125,28 @@ describe('MessageList nested tool calls', () => {
it('shows failed agent status and compact unavailable summary for Explore launch errors', () => {
useChatStore.setState({
messages: [
{
id: 'tool-agent',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-1',
input: { description: '探索整体架构', subagent_type: 'Explore' },
timestamp: 1,
},
{
id: 'result-agent',
type: 'tool_result',
toolUseId: 'agent-1',
content: `Agent type 'Explore' not found. Available agents: general-purpose`,
isError: true,
timestamp: 2,
},
],
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'tool-agent',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-1',
input: { description: '探索整体架构', subagent_type: 'Explore' },
timestamp: 1,
},
{
id: 'result-agent',
type: 'tool_result',
toolUseId: 'agent-1',
content: `Agent type 'Explore' not found. Available agents: general-purpose`,
isError: true,
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
@ -136,27 +159,31 @@ describe('MessageList nested tool calls', () => {
const longResult = '探索完成。让我将结果整合写入计划文件。第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。'
useChatStore.setState({
messages: [
{
id: 'tool-agent',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-1',
input: { description: '探索整体架构' },
timestamp: 1,
},
{
id: 'result-agent',
type: 'tool_result',
toolUseId: 'agent-1',
content: {
status: 'completed',
content: [{ type: 'text', text: longResult }],
},
isError: false,
timestamp: 2,
},
],
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'tool-agent',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-1',
input: { description: '探索整体架构' },
timestamp: 1,
},
{
id: 'result-agent',
type: 'tool_result',
toolUseId: 'agent-1',
content: {
status: 'completed',
content: [{ type: 'text', text: longResult }],
},
isError: false,
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
@ -180,26 +207,30 @@ describe('MessageList nested tool calls', () => {
})
useChatStore.setState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '请帮我探索整体架构',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: '先看 CLI 和服务端入口。',
timestamp: 2,
},
{
id: 'assistant-2',
type: 'assistant_text',
content: '再看 desktop 前后端边界。',
timestamp: 3,
},
],
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '请帮我探索整体架构',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: '先看 CLI 和服务端入口。',
timestamp: 2,
},
{
id: 'assistant-2',
type: 'assistant_text',
content: '再看 desktop 前后端边界。',
timestamp: 3,
},
],
}),
},
})
render(<MessageList />)

View File

@ -1,5 +1,6 @@
import { useRef, useEffect } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en'
import { UserMessage } from './UserMessage'
@ -63,7 +64,12 @@ export function buildRenderItems(messages: UIMessage[], toolUseIds: Set<string>)
}
export function MessageList() {
const { messages, chatState, streamingText, activeThinkingId } = useChatStore()
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 ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {

View File

@ -1,5 +1,6 @@
import { useState } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
import { DiffViewer } from './DiffViewer'
@ -110,7 +111,9 @@ function renderPermissionPreview(toolName: string, input: unknown) {
}
export function PermissionDialog({ requestId, toolName, input, description }: Props) {
const { respondToPermission, pendingPermission } = useChatStore()
const { respondToPermission } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const pendingPermission = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.pendingPermission : undefined)
const t = useTranslation()
const isPending = pendingPermission?.requestId === requestId
const [showRaw, setShowRaw] = useState(false)
@ -223,7 +226,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<Button
variant="primary"
size="sm"
onClick={() => respondToPermission(requestId, true)}
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, true)}
icon={
<span className="material-symbols-outlined text-[14px]">check</span>
}
@ -233,7 +236,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<Button
variant="ghost"
size="sm"
onClick={() => respondToPermission(requestId, true, 'always')}
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, true, 'always')}
icon={
<span className="material-symbols-outlined text-[14px]">verified</span>
}
@ -244,7 +247,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<Button
variant="danger"
size="sm"
onClick={() => respondToPermission(requestId, false)}
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, false)}
icon={
<span className="material-symbols-outlined text-[14px]">close</span>
}

View File

@ -1,4 +1,5 @@
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en'
@ -10,7 +11,12 @@ function formatElapsed(seconds: number): string {
}
export function StreamingIndicator() {
const { chatState, statusVerb, elapsedSeconds, tokenUsage } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const statusVerb = sessionState?.statusVerb ?? ''
const elapsedSeconds = sessionState?.elapsedSeconds ?? 0
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
const t = useTranslation()
// Translate known server-sent verbs (e.g. "Thinking", "Task started")

View File

@ -4,20 +4,12 @@ import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock'
import { PermissionDialog } from './PermissionDialog'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
describe('chat blocks', () => {
beforeEach(() => {
useChatStore.setState({
chatState: 'idle',
messages: [],
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
elapsedSeconds: 0,
})
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] })
useChatStore.setState({ sessions: {} })
})
it('keeps thinking collapsed by default', () => {
@ -72,13 +64,30 @@ describe('chat blocks', () => {
it('shows a diff preview for edit permission requests', () => {
useChatStore.setState({
pendingPermission: {
requestId: 'perm-1',
toolName: 'Edit',
input: {
file_path: '/tmp/example.ts',
old_string: 'const count = 1',
new_string: 'const count = 2',
sessions: {
'active-tab': {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: {
requestId: 'perm-1',
toolName: 'Edit',
input: {
file_path: '/tmp/example.ts',
old_string: 'const count = 1',
new_string: 'const count = 2',
},
},
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
elapsedTimer: null,
},
},
})
@ -99,6 +108,6 @@ describe('chat blocks', () => {
expect(container.textContent).toContain('Allow')
// react-diff-viewer-continued uses styled-components tables that don't
// fully render in jsdom, so we verify the DiffViewer wrapper is mounted
expect(container.querySelector('[class*="rounded-lg"]')).toBeTruthy()
expect(container.querySelector('[class*="rounded-[var(--radius-lg)]"]')).toBeTruthy()
})
})

View File

@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import type { PermissionMode } from '../../types/settings'
@ -26,6 +27,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
const t = useTranslation()
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
const activeTabId = useTabStore((s) => s.activeTabId)
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const [open, setOpen] = useState(false)
@ -126,7 +128,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
onChange?.(item.value)
} else {
void setPermissionMode(item.value)
setSessionPermissionMode(item.value)
if (activeTabId) setSessionPermissionMode(activeTabId, item.value)
}
setOpen(false)
}}
@ -208,7 +210,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
onChange?.('bypassPermissions')
} else {
void setPermissionMode('bypassPermissions')
setSessionPermissionMode('bypassPermissions')
if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions')
}
setConfirmDialog(false)
}}

View File

@ -5,6 +5,9 @@ import { ToastContainer } from '../shared/Toast'
import { useSettingsStore } from '../../stores/settingsStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
import { TabBar } from './TabBar'
import { useTabStore } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
@ -21,6 +24,12 @@ export function AppShell() {
try {
await initializeDesktopServerUrl()
await fetchSettings()
// Restore tabs from localStorage
await useTabStore.getState().restoreTabs()
const activeId = useTabStore.getState().activeTabId
if (activeId) {
useChatStore.getState().connectToSession(activeId)
}
if (!cancelled) {
setReady(true)
}
@ -92,6 +101,7 @@ export function AppShell() {
)}
<Sidebar />
<main id="content-area" className={`flex-1 flex flex-col overflow-hidden relative ${isTauri ? 'pt-[38px]' : ''}`}>
<TabBar />
<ContentRouter />
</main>
<ToastContainer />

View File

@ -1,5 +1,5 @@
import { useUIStore } from '../../stores/uiStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { EmptySession } from '../../pages/EmptySession'
import { ActiveSession } from '../../pages/ActiveSession'
@ -9,7 +9,7 @@ import { AgentTranscript } from '../../pages/AgentTranscript'
export function ContentRouter() {
const activeView = useUIStore((s) => s.activeView)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const activeTabId = useTabStore((s) => s.activeTabId)
const viewingAgentId = useTeamStore((s) => s.viewingAgentId)
if (activeView === 'settings') {
@ -33,7 +33,7 @@ export function ContentRouter() {
}
// Code view
if (!activeSessionId) {
if (!activeTabId) {
return <EmptySession />
}

View File

@ -4,6 +4,8 @@ import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { ProjectFilter } from './ProjectFilter'
import type { SessionListItem } from '../../types/session'
import { useTabStore } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
@ -205,7 +207,11 @@ export function Sidebar() {
/>
) : (
<button
onClick={() => { setActiveView('code'); setActiveSession(session.id) }}
onClick={() => {
setActiveView('code')
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
w-full flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm text-left rounded-[var(--radius-md)] transition-colors duration-200 group

View File

@ -1,11 +1,13 @@
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
export function StatusBar() {
const { currentModel } = useSettingsStore()
const { connectionState } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const connectionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.connectionState ?? 'disconnected' : 'disconnected')
const { sessions, activeSessionId } = useSessionStore()
const t = useTranslation()

View File

@ -0,0 +1,220 @@
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
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 [closingTabId, setClosingTabId] = useState<string | 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) => {
const sessionState = useChatStore.getState().sessions[sessionId]
const isRunning = sessionState && sessionState.chatState !== 'idle'
if (isRunning) {
setClosingTabId(sessionId)
return
}
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) {
disconnectSession(id)
closeTab(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) {
disconnectSession(id)
closeTab(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">
{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>
)}
<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>
{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>
)}
{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>
)}
{closingTabId && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/30">
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-6 max-w-sm w-full mx-4" style={{ boxShadow: 'var(--shadow-dropdown)' }}>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">{t('tabs.closeConfirmTitle')}</h3>
<p className="text-xs text-[var(--color-text-secondary)] mb-4">{t('tabs.closeConfirmMessage')}</p>
<div className="flex justify-end gap-2">
<button onClick={() => setClosingTabId(null)} className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]">
{t('common.cancel')}
</button>
<button
onClick={() => { closeTab(closingTabId); setClosingTabId(null) }}
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeConfirmKeep')}
</button>
<button
onClick={() => {
useChatStore.getState().stopGeneration(closingTabId)
disconnectSession(closingTabId)
closeTab(closingTabId)
setClosingTabId(null)
}}
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--color-brand)] text-white hover:opacity-90"
>
{t('tabs.closeConfirmStop')}
</button>
</div>
</div>
</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 }}
>
{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" />
)}
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
{tab.title || 'Untitled'}
</span>
<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>
)
}

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
export function useKeyboardShortcuts() {
@ -9,7 +10,8 @@ export function useKeyboardShortcuts() {
const closeModal = useUIStore((s) => s.closeModal)
const activeModal = useUIStore((s) => s.activeModal)
const stopGeneration = useChatStore((s) => s.stopGeneration)
const chatState = useChatStore((s) => s.chatState)
const activeTabId = useTabStore((s) => s.activeTabId)
const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle')
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@ -38,14 +40,14 @@ export function useKeyboardShortcuts() {
// Cmd+. — Stop generation
if (meta && e.key === '.') {
if (chatState !== 'idle') {
if (chatState !== 'idle' && activeTabId) {
e.preventDefault()
stopGeneration()
stopGeneration(activeTabId)
}
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [activeModal, chatState, closeModal, setActiveSession, setActiveView, stopGeneration])
}, [activeModal, activeTabId, chatState, closeModal, setActiveSession, setActiveView, stopGeneration])
}

View File

@ -434,6 +434,15 @@ export const en = {
'serverVerb.Thinking': 'Thinking',
'serverVerb.Task started': 'Task started',
'serverVerb.Task in progress': 'Task in progress',
// ─── 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',
} as const
export type TranslationKey = keyof typeof en

View File

@ -436,4 +436,13 @@ export const zh: Record<TranslationKey, string> = {
'serverVerb.Thinking': '思考中',
'serverVerb.Task started': '任务已启动',
'serverVerb.Task in progress': '任务进行中',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',
'tabs.closeOthers': '关闭其他',
'tabs.closeRight': '关闭右侧',
'tabs.closeConfirmTitle': '会话运行中',
'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
'tabs.closeConfirmKeep': '保持运行',
'tabs.closeConfirmStop': '停止并关闭',
}

View File

@ -1,4 +1,5 @@
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'
@ -7,18 +8,20 @@ import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
export function ActiveSession() {
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const activeTabId = useTabStore((s) => s.activeTabId)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const { chatState, tokenUsage } = useChatStore()
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
const session = sessions.find((s) => s.id === activeSessionId)
const session = sessions.find((s) => s.id === activeTabId)
useEffect(() => {
if (activeSessionId) {
connectToSession(activeSessionId)
if (activeTabId) {
connectToSession(activeTabId)
}
}, [activeSessionId, connectToSession])
}, [activeTabId, connectToSession])
const isActive = chatState !== 'idle'
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
@ -32,6 +35,8 @@ export function ActiveSession() {
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 */}

View File

@ -3,6 +3,7 @@ import { useTranslation } from '../i18n'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useUIStore } from '../stores/uiStore'
import { useTabStore } from '../stores/tabStore'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector'
@ -122,6 +123,7 @@ export function EmptySession() {
try {
const sessionId = await createSession(workDir || undefined)
setActiveView('code')
useTabStore.getState().openTab(sessionId, 'New Session')
connectToSession(sessionId)
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type,
@ -129,7 +131,7 @@ export function EmptySession() {
data: attachment.data,
mimeType: attachment.mimeType,
}))
sendMessage(text, attachmentPayload)
sendMessage(sessionId, text, attachmentPayload)
setInput('')
setAttachments([])
} catch (error) {

View File

@ -32,8 +32,39 @@ vi.mock('./teamStore', () => ({
},
}))
vi.mock('./tabStore', () => ({
useTabStore: {
getState: () => ({
updateTabStatus: vi.fn(),
updateTabTitle: vi.fn(),
}),
},
}))
vi.mock('./sessionStore', () => ({
useSessionStore: {
getState: () => ({
updateSessionTitle: vi.fn(),
}),
},
}))
vi.mock('./cliTaskStore', () => ({
useCLITaskStore: {
getState: () => ({
fetchSessionTasks: vi.fn(),
tasks: [],
clearTasks: vi.fn(),
setTasksFromTodos: vi.fn(),
markCompletedAndDismissed: vi.fn(),
refreshTasks: vi.fn(),
}),
},
}))
import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore'
const TEST_SESSION_ID = 'test-session-1'
const initialState = useChatStore.getState()
describe('chatStore history mapping', () => {
@ -41,19 +72,7 @@ describe('chatStore history mapping', () => {
sendMock.mockReset()
useChatStore.setState({
...initialState,
messages: [],
chatState: 'idle',
connectionState: 'disconnected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
connectedSessionId: null,
slashCommands: [],
sessions: {},
})
})
@ -95,7 +114,29 @@ describe('chatStore history mapping', () => {
})
it('keeps parent tool linkage for live tool events', () => {
useChatStore.getState().handleServerMessage({
// Initialize the session first
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_use_complete',
toolName: 'Read',
toolUseId: 'tool-1',
@ -103,7 +144,7 @@ describe('chatStore history mapping', () => {
parentToolUseId: 'agent-1',
})
useChatStore.getState().handleServerMessage({
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_result',
toolUseId: 'tool-1',
content: 'ok',
@ -111,7 +152,7 @@ describe('chatStore history mapping', () => {
parentToolUseId: 'agent-1',
})
expect(useChatStore.getState().messages).toMatchObject([
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'tool_use',
toolUseId: 'tool-1',
@ -126,13 +167,32 @@ describe('chatStore history mapping', () => {
})
it('sends permission mode updates to the active session only', () => {
useChatStore.getState().setSessionPermissionMode('acceptEdits')
useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits')
expect(sendMock).not.toHaveBeenCalled()
useChatStore.setState({ connectedSessionId: 'session-1' })
useChatStore.getState().setSessionPermissionMode('acceptEdits')
useChatStore.setState({
sessions: {
'session-1': {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
elapsedTimer: null,
},
},
})
useChatStore.getState().setSessionPermissionMode('session-1', 'acceptEdits')
expect(sendMock).toHaveBeenCalledWith({
expect(sendMock).toHaveBeenCalledWith('session-1', {
type: 'set_permission_mode',
mode: 'acceptEdits',
})

View File

@ -4,6 +4,7 @@ import { sessionsApi } from '../api/sessions'
import { useTeamStore } from './teamStore'
import { useSessionStore } from './sessionStore'
import { useCLITaskStore } from './cliTaskStore'
import { useTabStore } from './tabStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
@ -11,7 +12,7 @@ import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage,
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
type ChatStore = {
export type PerSessionState = {
messages: UIMessage[]
chatState: ChatState
connectionState: ConnectionState
@ -29,31 +30,11 @@ type ChatStore = {
tokenUsage: TokenUsage
elapsedSeconds: number
statusVerb: string
connectedSessionId: string | null
slashCommands: Array<{ name: string; description: string }>
// Actions
connectToSession: (sessionId: string) => void
disconnectSession: () => void
sendMessage: (content: string, attachments?: AttachmentRef[]) => void
respondToPermission: (requestId: string, allowed: boolean, rule?: string) => void
setSessionPermissionMode: (mode: PermissionMode) => void
stopGeneration: () => void
loadHistory: (sessionId: string) => Promise<void>
clearMessages: () => void
handleServerMessage: (msg: ServerMessage) => void
elapsedTimer: ReturnType<typeof setInterval> | null
}
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
/** Track tool_use IDs for task-related tools, so we can refresh on tool_result */
const pendingTaskToolUseIds = new Set<string>()
let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
let elapsedTimer: ReturnType<typeof setInterval> | null = null
export const useChatStore = create<ChatStore>((set, get) => ({
const DEFAULT_SESSION_STATE: PerSessionState = {
messages: [],
chatState: 'idle',
connectionState: 'disconnected',
@ -66,97 +47,127 @@ export const useChatStore = create<ChatStore>((set, get) => ({
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
connectedSessionId: null,
slashCommands: [],
elapsedTimer: null,
}
connectToSession: (sessionId: string) => {
const current = get().connectedSessionId
if (current === sessionId) return
function createDefaultSessionState(): PerSessionState {
return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } }
}
// Disconnect previous
if (current) wsManager.disconnect()
type ChatStore = {
sessions: Record<string, PerSessionState>
set({
connectedSessionId: sessionId,
connectionState: 'connecting',
messages: [],
chatState: 'idle',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
elapsedSeconds: 0,
slashCommands: [],
})
getSession: (sessionId: string) => PerSessionState
connectToSession: (sessionId: string) => void
disconnectSession: (sessionId: string) => void
sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void
setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
stopGeneration: (sessionId: string) => void
loadHistory: (sessionId: string) => Promise<void>
clearMessages: (sessionId: string) => void
handleServerMessage: (sessionId: string, msg: ServerMessage) => void
}
// Clear all previous handlers before registering new ones
// This prevents handler accumulation causing message duplication
wsManager.clearHandlers()
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
const pendingTaskToolUseIds = new Set<string>()
let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
/** Helper: immutably update a specific session within the sessions record */
function updateSessionIn(
sessions: Record<string, PerSessionState>,
sessionId: string,
updater: (s: PerSessionState) => Partial<PerSessionState>,
): Record<string, PerSessionState> {
const session = sessions[sessionId]
if (!session) return sessions
return { ...sessions, [sessionId]: { ...session, ...updater(session) } }
}
export const useChatStore = create<ChatStore>((set, get) => ({
sessions: {},
getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(),
connectToSession: (sessionId) => {
const existing = get().sessions[sessionId]
if (existing && existing.connectionState !== 'disconnected') return
set((s) => ({
sessions: {
...s.sessions,
[sessionId]: {
...createDefaultSessionState(),
connectionState: 'connecting',
messages: existing?.messages ?? [],
},
},
}))
wsManager.clearHandlers(sessionId)
wsManager.connect(sessionId)
wsManager.onMessage((msg) => {
wsManager.onMessage(sessionId, (msg) => {
if (msg.type === 'connected') {
set({ connectionState: 'connected' })
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ connectionState: 'connected' })) }))
}
get().handleServerMessage(msg)
get().handleServerMessage(sessionId, msg)
})
// Load history and tasks
get().loadHistory(sessionId)
useCLITaskStore.getState().fetchSessionTasks(sessionId)
sessionsApi.getSlashCommands(sessionId)
.then(({ commands }) => {
if (get().connectedSessionId === sessionId) {
set({ slashCommands: commands })
if (get().sessions[sessionId]) {
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) }))
}
})
.catch(() => {
if (get().connectedSessionId === sessionId) {
set({ slashCommands: [] })
if (get().sessions[sessionId]) {
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) }))
}
})
},
disconnectSession: () => {
wsManager.disconnect()
if (elapsedTimer) clearInterval(elapsedTimer)
useCLITaskStore.getState().clearTasks()
set({ connectedSessionId: null, chatState: 'idle' })
disconnectSession: (sessionId) => {
const session = get().sessions[sessionId]
if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
wsManager.disconnect(sessionId)
set((s) => {
const { [sessionId]: _, ...rest } = s.sessions
return { sessions: rest }
})
},
sendMessage: (content: string, attachments?: AttachmentRef[]) => {
sendMessage: (sessionId, content, attachments?) => {
const userFacingContent = content.trim()
const uiAttachments: UIAttachment[] | undefined =
attachments && attachments.length > 0
? attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name || attachment.path || attachment.mimeType || attachment.type,
data: attachment.data,
mimeType: attachment.mimeType,
? attachments.map((a) => ({
type: a.type,
name: a.name || a.path || a.mimeType || a.type,
data: a.data,
mimeType: a.mimeType,
}))
: undefined
// If all tasks are completed, inline the task summary before the new user message
const taskStore = useCLITaskStore.getState()
const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed')
// Add user message to UI (with optional task summary before it)
set((s) => {
const newMessages = [...s.messages]
const session = s.sessions[sessionId]
if (!session) return s
const newMessages = [...session.messages]
if (allTasksDone) {
newMessages.push({
id: nextId(),
type: 'task_summary',
tasks: taskStore.tasks.map((t) => ({
id: t.id,
subject: t.subject,
status: t.status,
activeForm: t.activeForm,
})),
tasks: taskStore.tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })),
timestamp: Date.now(),
})
// Clear sticky task bar since we inlined the summary
taskStore.clearTasks()
}
newMessages.push({
@ -166,64 +177,66 @@ export const useChatStore = create<ChatStore>((set, get) => ({
attachments: uiAttachments,
timestamp: Date.now(),
})
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
const timer = setInterval(() => {
set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) }))
}, 1000)
return {
messages: newMessages,
chatState: 'thinking',
elapsedSeconds: 0,
streamingText: '',
statusVerb: randomSpinnerVerb(),
sessions: {
...s.sessions,
[sessionId]: {
...session,
messages: newMessages,
chatState: 'thinking',
elapsedSeconds: 0,
streamingText: '',
statusVerb: randomSpinnerVerb(),
elapsedTimer: timer,
},
},
}
})
// Start elapsed timer
if (elapsedTimer) clearInterval(elapsedTimer)
elapsedTimer = setInterval(() => {
set((s) => ({ elapsedSeconds: s.elapsedSeconds + 1 }))
}, 1000)
wsManager.send({ type: 'user_message', content, attachments })
wsManager.send(sessionId, { type: 'user_message', content, attachments })
},
respondToPermission: (requestId: string, allowed: boolean, rule?: string) => {
wsManager.send({ type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
set({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })
respondToPermission: (sessionId, requestId, allowed, rule?) => {
wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
},
setSessionPermissionMode: (mode) => {
if (!get().connectedSessionId) return
wsManager.send({ type: 'set_permission_mode', mode })
setSessionPermissionMode: (sessionId, mode) => {
if (!get().sessions[sessionId]) return
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
},
stopGeneration: () => {
wsManager.send({ type: 'stop_generation' })
if (elapsedTimer) clearInterval(elapsedTimer)
set({ chatState: 'idle' })
stopGeneration: (sessionId) => {
wsManager.send(sessionId, { type: 'stop_generation' })
set((s) => {
const session = s.sessions[sessionId]
if (!session) return s
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
return { sessions: { ...s.sessions, [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null } } }
})
},
loadHistory: async (sessionId: string) => {
loadHistory: async (sessionId) => {
try {
const { messages } = await sessionsApi.getMessages(sessionId)
const uiMessages = mapHistoryMessagesToUiMessages(messages)
set((state) => {
if (state.connectedSessionId !== sessionId || state.messages.length > 0) {
return state
}
return { ...state, messages: uiMessages }
const session = state.sessions[sessionId]
if (!session || session.messages.length > 0) return state
return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages })) }
})
// Extract the last TodoWrite input from history so TaskBar shows for V1 sessions
const lastTodos = extractLastTodoWriteFromHistory(messages)
if (lastTodos && lastTodos.length > 0) {
const taskStore = useCLITaskStore.getState()
// Only set if V2 task fetch didn't already populate tasks
if (taskStore.tasks.length === 0) {
taskStore.setTasksFromTodos(lastTodos)
}
if (taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos)
}
// For both V1 and V2: if all tasks completed and the user already
// continued chatting, suppress the sticky TaskBar on page refresh.
if (hasUserMessagesAfterTaskCompletion(messages)) {
useCLITaskStore.getState().markCompletedAndDismissed()
}
@ -232,89 +245,79 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
},
clearMessages: () => set({ messages: [], streamingText: '', chatState: 'idle' }),
clearMessages: (sessionId) => {
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) }))
},
handleServerMessage: (sessionId, msg) => {
const update = (updater: (session: PerSessionState) => Partial<PerSessionState>) => {
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) }))
}
handleServerMessage: (msg: ServerMessage) => {
switch (msg.type) {
case 'connected':
break
case 'status':
set({
update((session) => ({
chatState: msg.state,
// Only override statusVerb if the server sends something other than
// the generic 'Thinking' — otherwise keep the random verb we picked
// in sendMessage so the user sees fun loading text.
...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}),
...(msg.tokens ? { tokenUsage: { ...get().tokenUsage, output_tokens: msg.tokens } } : {}),
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
})
if (msg.state === 'idle' && elapsedTimer) {
clearInterval(elapsedTimer)
elapsedTimer = null
}))
if (msg.state === 'idle') {
const session = get().sessions[sessionId]
if (session?.elapsedTimer) {
clearInterval(session.elapsedTimer)
update(() => ({ elapsedTimer: null }))
}
}
// Sync tab status
useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
break
case 'content_start': {
// Flush any accumulated streamingText as assistant_text BEFORE
// switching to the next block. This preserves intermediate text
// segments between tool calls (e.g. "项目已经有 node_modules直接启动").
const pendingText = get().streamingText.trim()
const session = get().sessions[sessionId]
if (!session) break
const pendingText = session.streamingText.trim()
if (pendingText) {
set((s) => ({
messages: [...s.messages, {
id: nextId(),
type: 'assistant_text',
content: pendingText,
timestamp: Date.now(),
}],
update((s) => ({
messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }],
streamingText: '',
}))
}
if (msg.blockType === 'text') {
set({ streamingText: '', chatState: 'streaming', activeThinkingId: null })
update(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null }))
} else if (msg.blockType === 'tool_use') {
set({
update(() => ({
activeToolUseId: msg.toolUseId ?? null,
activeToolName: msg.toolName ?? null,
streamingToolInput: '',
chatState: 'tool_executing',
activeThinkingId: null,
})
}))
}
break
}
case 'content_delta':
if (msg.text !== undefined) {
set((s) => ({ streamingText: s.streamingText + msg.text }))
}
if (msg.toolInput !== undefined) {
set((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
}
if (msg.text !== undefined) update((s) => ({ streamingText: s.streamingText + msg.text }))
if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
break
case 'thinking':
// Merge consecutive thinking deltas into one message.
// Also flush any pending streamingText first — otherwise the text
// becomes invisible because MessageList only renders streamingText
// when chatState === 'streaming', and we're about to set it to 'thinking'.
set((s) => {
update((s) => {
const pendingText = s.streamingText.trim()
const base = pendingText
? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }]
: s.messages
const last = base[base.length - 1]
if (last && last.type === 'thinking') {
// Append to existing thinking message
const updated = [...base]
updated[updated.length - 1] = { ...last, content: last.content + msg.text }
return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' }
}
const id = nextId()
// Create new thinking message
return {
messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }],
chatState: 'thinking',
@ -325,49 +328,33 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break
case 'tool_use_complete': {
const toolName = msg.toolName || get().activeToolName || 'unknown'
set((s) => ({
const session = get().sessions[sessionId]
const toolName = msg.toolName || session?.activeToolName || 'unknown'
update((s) => ({
messages: [...s.messages, {
id: nextId(),
type: 'tool_use',
toolName,
id: nextId(), type: 'tool_use', toolName,
toolUseId: msg.toolUseId || s.activeToolUseId || '',
input: msg.input,
timestamp: Date.now(),
parentToolUseId: msg.parentToolUseId,
input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
}],
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
streamingToolInput: '',
activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '',
}))
// TodoWrite: input contains the full todo list — update tasks immediately
if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) {
useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos)
} else if (TASK_TOOL_NAMES.has(toolName)) {
// V2 task tools — refresh will happen on tool_result
// when the tool has actually finished executing and written to disk
const useId = msg.toolUseId || get().activeToolUseId
const useId = msg.toolUseId || session?.activeToolUseId
if (useId) pendingTaskToolUseIds.add(useId)
}
break
}
case 'tool_result':
set((s) => ({
update((s) => ({
messages: [...s.messages, {
id: nextId(),
type: 'tool_result',
toolUseId: msg.toolUseId,
content: msg.content,
isError: msg.isError,
timestamp: Date.now(),
parentToolUseId: msg.parentToolUseId,
id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId,
content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
}],
chatState: 'thinking',
activeThinkingId: null,
chatState: 'thinking', activeThinkingId: null,
}))
// Refresh tasks after a task tool has finished executing
if (pendingTaskToolUseIds.has(msg.toolUseId)) {
pendingTaskToolUseIds.delete(msg.toolUseId)
useCLITaskStore.getState().refreshTasks()
@ -375,230 +362,118 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break
case 'permission_request':
set({
pendingPermission: {
requestId: msg.requestId,
toolName: msg.toolName,
input: msg.input,
description: msg.description,
},
update((s) => ({
pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description },
chatState: 'permission_pending',
activeThinkingId: null,
})
set((s) => ({
messages: [...s.messages, {
id: nextId(),
type: 'permission_request',
requestId: msg.requestId,
toolName: msg.toolName,
input: msg.input,
description: msg.description,
timestamp: Date.now(),
id: nextId(), type: 'permission_request', requestId: msg.requestId,
toolName: msg.toolName, input: msg.input, description: msg.description, timestamp: Date.now(),
}],
}))
break
case 'message_complete': {
// Flush streaming text as a message
const text = get().streamingText
const session = get().sessions[sessionId]
if (!session) break
const text = session.streamingText
if (text) {
set((s) => ({
update((s) => ({
messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }],
streamingText: '',
}))
}
set({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null })
if (elapsedTimer) {
clearInterval(elapsedTimer)
elapsedTimer = null
}
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null }))
break
}
case 'error':
set((s) => ({
update((s) => ({
messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }],
chatState: 'idle',
activeThinkingId: null,
chatState: 'idle', activeThinkingId: null,
}))
if (elapsedTimer) {
clearInterval(elapsedTimer)
elapsedTimer = null
useTabStore.getState().updateTabStatus(sessionId, 'error')
{
const session = get().sessions[sessionId]
if (session?.elapsedTimer) {
clearInterval(session.elapsedTimer)
update(() => ({ elapsedTimer: null }))
}
}
break
case 'team_created':
useTeamStore.getState().handleTeamCreated(msg.teamName)
break
case 'team_update':
useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members)
break
case 'team_deleted':
useTeamStore.getState().handleTeamDeleted(msg.teamName)
break
case 'task_update':
break
case 'session_title_updated':
useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title)
useTabStore.getState().updateTabTitle(msg.sessionId, msg.title)
break
case 'system_notification':
// Cache slash commands from CLI init
if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) {
set({ slashCommands: msg.data as Array<{ name: string; description: string }> })
update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> }))
}
break
case 'pong':
break
}
},
}))
type AssistantHistoryBlock = {
type: string
text?: string
thinking?: string
name?: string
id?: string
input?: unknown
}
// ─── History mapping helpers (unchanged from original) ─────────
type UserHistoryBlock = {
type: string
text?: string
tool_use_id?: string
content?: unknown
is_error?: boolean
source?: { data?: string }
mimeType?: string
media_type?: string
name?: string
}
type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string }
export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] {
const uiMessages: UIMessage[] = []
for (const msg of messages) {
const timestamp = new Date(msg.timestamp).getTime()
if (msg.type === 'user' && typeof msg.content === 'string') {
uiMessages.push({
id: msg.id || nextId(),
type: 'user_text',
content: msg.content,
timestamp,
})
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
continue
}
if (msg.type === 'assistant' && typeof msg.content === 'string') {
uiMessages.push({
id: msg.id || nextId(),
type: 'assistant_text',
content: msg.content,
timestamp,
model: msg.model,
})
uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model })
continue
}
// Server marks assistant messages containing tool_use blocks as type 'tool_use',
// but the content array structure is the same as 'assistant' — handle both.
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
for (const block of msg.content as AssistantHistoryBlock[]) {
if (block.type === 'thinking' && block.thinking) {
uiMessages.push({
id: nextId(),
type: 'thinking',
content: block.thinking,
timestamp,
})
} else if (block.type === 'text' && block.text) {
uiMessages.push({
id: nextId(),
type: 'assistant_text',
content: block.text,
timestamp,
model: msg.model,
})
} else if (block.type === 'tool_use') {
uiMessages.push({
id: nextId(),
type: 'tool_use',
toolName: block.name ?? 'unknown',
toolUseId: block.id ?? '',
input: block.input,
timestamp,
parentToolUseId: msg.parentToolUseId,
})
}
if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
else if (block.type === 'text' && block.text) uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model })
else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId })
}
continue
}
// Server marks user messages containing tool_result blocks as type 'tool_result',
// but the content array structure is the same as 'user' — handle both.
if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) {
const textParts: string[] = []
const attachments: UIAttachment[] = []
for (const block of msg.content as UserHistoryBlock[]) {
if (block.type === 'text' && block.text) {
textParts.push(block.text)
} else if (block.type === 'image') {
attachments.push({
type: 'image',
name: block.name || 'image',
data: block.source?.data,
mimeType: block.mimeType || block.media_type,
})
} else if (block.type === 'file') {
attachments.push({
type: 'file',
name: block.name || 'file',
})
} else if (block.type === 'tool_result') {
uiMessages.push({
id: nextId(),
type: 'tool_result',
toolUseId: block.tool_use_id ?? '',
content: block.content,
isError: !!block.is_error,
timestamp,
parentToolUseId: msg.parentToolUseId,
})
}
if (block.type === 'text' && block.text) textParts.push(block.text)
else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type })
else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' })
else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
}
if (textParts.length > 0 || attachments.length > 0) {
uiMessages.push({
id: nextId(),
type: 'user_text',
content: textParts.join('\n'),
attachments: attachments.length > 0 ? attachments : undefined,
timestamp,
})
uiMessages.push({ id: nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp })
}
}
}
return uiMessages
}
/** Scan history messages for the last TodoWrite tool_use and return the todos array.
* Returns null if all todos were completed and the user continued chatting after. */
function extractLastTodoWriteFromHistory(
messages: MessageEntry[],
): Array<{ content: string; status: string; activeForm?: string }> | null {
function extractLastTodoWriteFromHistory(messages: MessageEntry[]): Array<{ content: string; status: string; activeForm?: string }> | null {
let foundIndex = -1
let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null
// Walk backwards to find the most recent TodoWrite
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
@ -617,45 +492,28 @@ function extractLastTodoWriteFromHistory(
if (todos) break
}
}
if (!todos) return null
// If all todos are completed and there are user messages after, the tasks
// were already inlined — don't re-show the sticky bar.
const allDone = todos.every((t) => t.status === 'completed')
if (allDone) {
for (let i = foundIndex + 1; i < messages.length; i++) {
const msg = messages[i]!
if (msg.type === 'user' && msg.content) return null
if (messages[i]!.type === 'user' && messages[i]!.content) return null
}
}
return todos
}
const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
/** Check if there are user messages after the last task-related tool call.
* Used to determine if completed tasks should be shown as sticky or inline. */
function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean {
// Find the index of the last task-related tool call
let lastTaskIndex = -1
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
const blocks = msg.content as AssistantHistoryBlock[]
if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) {
lastTaskIndex = i
break
}
if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break }
}
}
if (lastTaskIndex < 0) return false
// Check for user messages after the last task tool
for (let i = lastTaskIndex + 1; i < messages.length; i++) {
if (messages[i]!.type === 'user') return true
}
for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true }
return false
}

View File

@ -0,0 +1,128 @@
import { create } from 'zustand'
import { sessionsApi } from '../api/sessions'
const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
export type Tab = {
sessionId: string
title: string
status: 'idle' | 'running' | 'error'
}
type TabPersistence = {
openTabs: Array<{ sessionId: string; title: string }>
activeTabId: string | null
}
type TabStore = {
tabs: Tab[]
activeTabId: string | null
openTab: (sessionId: string, title: string) => void
closeTab: (sessionId: string) => void
setActiveTab: (sessionId: string) => void
updateTabTitle: (sessionId: string, title: string) => void
updateTabStatus: (sessionId: string, status: Tab['status']) => void
saveTabs: () => void
restoreTabs: () => Promise<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 (activeTabId === sessionId) {
if (newTabs.length === 0) {
newActiveId = null
} else if (index >= newTabs.length) {
newActiveId = newTabs[newTabs.length - 1]!.sessionId
} else {
newActiveId = newTabs[index]!.sessionId
}
}
set({ tabs: newTabs, activeTabId: newActiveId })
get().saveTabs()
},
setActiveTab: (sessionId) => {
set({ activeTabId: sessionId })
get().saveTabs()
},
updateTabTitle: (sessionId, title) => {
set((s) => ({
tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, title } : t)),
}))
get().saveTabs()
},
updateTabStatus: (sessionId, status) => {
set((s) => ({
tabs: s.tabs.map((t) => (t.sessionId === sessionId ? { ...t, status } : t)),
}))
},
saveTabs: () => {
const { tabs, activeTabId } = get()
const data: TabPersistence = {
openTabs: tabs.map((t) => ({ sessionId: t.sessionId, title: t.title })),
activeTabId,
}
try {
localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data))
} catch { /* noop */ }
},
restoreTabs: async () => {
try {
const raw = localStorage.getItem(TAB_STORAGE_KEY)
if (!raw) return
const data = JSON.parse(raw) as TabPersistence
if (!data.openTabs || data.openTabs.length === 0) return
const { sessions } = await sessionsApi.list({ limit: 200 })
const existingIds = new Set(sessions.map((s) => s.id))
const validTabs: Tab[] = data.openTabs
.filter((t) => existingIds.has(t.sessionId))
.map((t) => ({
sessionId: t.sessionId,
title: sessions.find((s) => s.id === t.sessionId)?.title || t.title,
status: 'idle' as const,
}))
if (validTabs.length === 0) return
const activeId = data.activeTabId && validTabs.some((t) => t.sessionId === data.activeTabId)
? data.activeTabId
: validTabs[0]!.sessionId
set({ tabs: validTabs, activeTabId: activeId })
} catch { /* noop */ }
},
}))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,221 @@
# Multi-Tab Sessions Design Spec
## Overview
Desktop webapp (Tauri + React + Zustand) 新增多 Tab 功能,允许用户同时打开多个 Session 并行工作。类似 VS Code 的编辑器 Tab 与文件浏览器的关系Sidebar 浏览所有 SessionTab 栏管理当前打开的 Session。
## Tech Stack
- **Desktop App**: Tauri v2 + React 18 + Zustand 5 + Tailwind CSS 4 + Vite
- **Backend Server**: `src/server/` — 已支持多 Session 并发 (`maxSessions` 配置)
- **通信协议**: 每个 Session 独立 WebSocket (`/ws/${sessionId}`) + REST API
## Key Design Decisions
| 决策 | 选择 | 理由 |
|------|------|------|
| Tab 与 Sidebar 关系 | 独立共存 (VS Code 模式) | Sidebar 浏览全部历史, Tab 管理当前打开的 |
| Tab 栏位置 | 内容区域顶部 (Sidebar 右侧上方) | 不影响 Sidebar 布局 |
| 并发策略 | 每个 Tab 独立 WebSocket 连接 | 真正并行, 后端已支持, WS 连接轻量 |
| Tab 数量限制 | 不限制 | 用户自由管理 |
| Tab 溢出处理 | 固定宽度 + 左右滚动箭头 | VS Code 风格 |
| 关闭运行中 Tab | 可配置 (默认弹确认框) | 配置项: ask / background / stop |
| Tab 持久化 | 可配置 (默认恢复) | 存 localStorage, 启动时恢复 |
| Sidebar 打开行为 | 去重 + 聚焦 | 已打开则跳转, 否则新 Tab |
| 新建 Session 入口 | 仅 Sidebar | 保持单一入口 |
## Architecture Changes
### 1. New Tab Store (`tabStore.ts`)
新增 Zustand store 管理 Tab 状态:
```ts
interface Tab {
sessionId: string
title: string
status: 'idle' | 'running' | 'error'
}
interface TabStore {
tabs: Tab[]
activeTabId: string | null // sessionId
openTab(sessionId: string, title: string): void
closeTab(sessionId: string): void
setActiveTab(sessionId: string): void
updateTabTitle(sessionId: string, title: string): void
updateTabStatus(sessionId: string, status: Tab['status']): void
reorderTabs(fromIndex: number, toIndex: number): void
// Persistence
saveTabs(): void
restoreTabs(): Tab[]
}
```
### 2. chatStore Refactor
从单 Session 改为多 Session 并发:
**Before**: 单一 `messages[]` + 单一 `connectedSessionId`
**After**: 按 sessionId 隔离的 session map
```ts
interface PerSessionState {
messages: UIMessage[]
connectionState: 'connecting' | 'connected' | 'disconnected'
streamingState: StreamingState
permissionRequests: PermissionRequest[]
tasks: TaskState[]
}
interface ChatStore {
sessions: Record<string, PerSessionState>
connectToSession(sessionId: string): void
disconnectSession(sessionId: string): void
sendMessage(sessionId: string, content: string, attachments?: AttachmentRef[]): void
// ... per-session methods all accept sessionId
}
```
### 3. WebSocket Manager Refactor
从单例连接改为多连接管理:
**Before**: 全局一个 WS 连接
**After**: 按 sessionId 管理多个独立 WS 连接
```ts
class WebSocketManager {
connections: Map<string, WebSocketConnection>
connect(sessionId: string): void
disconnect(sessionId: string): void
disconnectAll(): void
getConnection(sessionId: string): WebSocketConnection | undefined
send(sessionId: string, message: ClientMessage): void
}
```
### 4. UI Components
#### 4.1 TabBar Component (New)
位置: 内容区域顶部, Sidebar 右侧上方。
- 固定 Tab 宽度, 显示 Session 标题 (截断) + 状态指示 + 关闭按钮
- 超出容器宽度时显示左右滚动箭头
- 当前活跃 Tab 高亮
- Tab 运行中显示状态指示 (spinner/dot)
- 右键上下文菜单: 关闭、关闭其他、关闭右侧
#### 4.2 Layout Changes (AppShell / ContentRouter)
```
Before:
┌────────────────────────────────────────┐
│ Tauri Drag Region │
├──────────┬─────────────────────────────┤
│ Sidebar │ ContentRouter │
│ │ (single active session) │
└──────────┴─────────────────────────────┘
After:
┌────────────────────────────────────────┐
│ Tauri Drag Region │
├──────────┬─────────────────────────────┤
│ Sidebar │ TabBar │
│ ├─────────────────────────────┤
│ │ ContentRouter │
│ │ (active tab's session) │
└──────────┴─────────────────────────────┘
```
#### 4.3 ActiveSession Changes
当前 ActiveSession 组件从 `sessionStore.activeSessionId` 获取 session。
改为从 `tabStore.activeTabId` 获取, 并从 `chatStore.sessions[activeTabId]` 读取消息。
### 5. Settings Additions
新增两个配置项到 Settings 页面:
- **关闭运行中 Tab 的行为**: `ask` | `background` | `stop` (默认 `ask`)
- **启动时恢复 Tab**: `boolean` (默认 `true`)
### 6. Sidebar Integration
修改 Sidebar 点击行为:
- 点击 Session → 检查 tabStore 是否已打开该 Session
- 已打开: `setActiveTab(sessionId)` 聚焦
- 未打开: `openTab(sessionId, title)` 创建新 Tab + 连接 WS
- 新建 Session → 创建后自动 `openTab`
### 7. Tab Close Flow
```
User clicks X on tab
→ Is session running?
→ No: close tab, disconnect WS, cleanup chatStore session state
→ Yes: check setting `tabCloseWithRunningSession`
→ 'ask': show confirm dialog (3 options: keep running / stop / cancel)
→ 'background': close tab, keep WS + session running
→ 'stop': send stop_generation, close tab, disconnect WS
```
### 8. Tab Persistence
存储到 `localStorage`:
```ts
{
openTabs: Array<{ sessionId: string; title: string }>
activeTabId: string | null
}
```
启动时:
1. 读取 localStorage
2. 检查 `restoreTabsOnStartup` 配置
3. 校验每个 sessionId 是否仍存在 (API check)
4. 恢复有效 Tab, 连接活跃 Tab 的 WS
## Backend Impact
**无需后端改动**。后端已支持:
- 多 Session 并发 (`maxSessions` 配置)
- 独立 WebSocket 端点 (`/ws/${sessionId}`)
- Session CRUD REST API
- 独立的消息历史存储
前端并行打开多个 WS 连接即可。
## Files to Modify
### New Files
- `desktop/src/stores/tabStore.ts`
- `desktop/src/components/layout/TabBar.tsx`
- `desktop/src/components/layout/TabCloseDialog.tsx`
### Modified Files
- `desktop/src/stores/chatStore.ts` — 多 session 状态隔离
- `desktop/src/api/websocket.ts` — 多连接管理
- `desktop/src/components/layout/AppShell.tsx` — 集成 TabBar
- `desktop/src/components/layout/ContentRouter.tsx` — 从 tabStore 获取 activeTabId
- `desktop/src/components/layout/Sidebar.tsx` — 点击行为改为 openTab
- `desktop/src/pages/ActiveSession.tsx` — 从 chatStore.sessions[id] 读取
- `desktop/src/pages/Settings.tsx` — 新增 Tab 相关设置项
- `desktop/src/stores/uiStore.ts` — 可能需要调整 activeView 逻辑
- `desktop/src/stores/cliTaskStore.ts` — 按 sessionId 隔离 task 状态
## Performance Considerations
- 每个 WS 连接占用极少资源, 10 个以内无需担心
- 消息缓存在内存中, 极端情况 (大量 Tab + 长对话) 可能占用较多内存
- 非活跃 Tab 不渲染 DOM (React 条件渲染), 无 UI 性能开销
- Tab 切换无需重新加载消息 (已缓存), 切换体验流畅
## Testing Strategy
- Unit tests: tabStore CRUD, persistence, close flow logic
- Integration tests: Sidebar → Tab → WS 连接全链路
- Edge cases: 关闭最后一个 Tab、恢复已删除的 Session、大量 Tab 滚动