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", "@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.3", "typescript": "^5.9.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vitest": "^3.0.4" "vitest": "^3.0.4"
}, },

View File

@ -12,8 +12,10 @@ import { ToolInspection } from '../pages/ToolInspection'
// Layout components (chrome is now here, not in pages) // Layout components (chrome is now here, not in pages)
import { AppShell } from '../components/layout/AppShell' import { AppShell } from '../components/layout/AppShell'
import { Sidebar } from '../components/layout/Sidebar'
import { UserMessage } from '../components/chat/UserMessage' import { UserMessage } from '../components/chat/UserMessage'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore'
/** /**
* Core rendering tests: content-only pages must render without crashing * 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', () => { 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 />) const { container } = render(<ActiveSession />)
// Should have the title area and chat input // Should have the title area and chat input
expect(container.innerHTML).toContain('Untitled Session') expect(container.innerHTML).toContain('Untitled Session')
// ChatInput has a textarea // ChatInput has a textarea
expect(container.querySelector('textarea')).toBeInTheDocument() expect(container.querySelector('textarea')).toBeInTheDocument()
expect(container.innerHTML).not.toContain('Preview') 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', () => { 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 />) render(<ActiveSession />)
expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument()
useChatStore.setState({ chatState: 'idle' }) useChatStore.setState({ sessions: {} })
}) })
it('AgentTeams renders team strip and members', () => { it('AgentTeams renders team strip and members', () => {
@ -102,11 +149,11 @@ describe('Chat attachments', () => {
describe('AppShell layout renders chrome', () => { describe('AppShell layout renders chrome', () => {
it('AppShell renders sidebar and session shell', () => { it('AppShell renders sidebar and session shell', () => {
const { container } = render(<AppShell />) const { container } = render(<Sidebar />)
expect(container.querySelector('aside')).toBeInTheDocument() expect(container.querySelector('aside')).toBeInTheDocument()
expect(container.innerHTML).toContain('New session') expect(container.innerHTML).toContain('New session')
expect(container.innerHTML).toContain('Scheduled') 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 { useState } from 'react'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button' import { Button } from '../shared/Button'
@ -47,6 +48,7 @@ function parseInput(input: unknown): Question[] {
export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) { export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
const { sendMessage } = useChatStore() const { sendMessage } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const t = useTranslation() const t = useTranslation()
const questions = parseInput(input) const questions = parseInput(input)
const [activeTab, setActiveTab] = useState(0) const [activeTab, setActiveTab] = useState(0)
@ -82,7 +84,8 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
if (!response) return if (!response) return
setSubmitted(true) setSubmitted(true)
sendMessage(response) if (!activeTabId) return
sendMessage(activeTabId, response)
} }
// All questions must be answered (via selection or free text) to enable submit // 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 { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { sessionsApi } from '../../api/sessions' import { sessionsApi } from '../../api/sessions'
import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { PermissionModeSelector } from '../controls/PermissionModeSelector'
@ -43,7 +44,11 @@ export function ChatInput() {
const slashMenuRef = useRef<HTMLDivElement>(null) const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null) const fileSearchRef = useRef<FileSearchMenuHandle>(null)
const slashItemRefs = useRef<(HTMLButtonElement | 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 activeSessionId = useSessionStore((state) => state.activeSessionId)
const activeSession = useSessionStore((state) => state.sessions.find((session) => session.id === state.activeSessionId) ?? null) const activeSession = useSessionStore((state) => state.sessions.find((session) => session.id === state.activeSessionId) ?? null)
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null) const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
@ -213,7 +218,7 @@ export function ChatInput() {
mimeType: attachment.mimeType, mimeType: attachment.mimeType,
})) }))
sendMessage(text, attachmentPayload) sendMessage(activeTabId!, text, attachmentPayload)
setInput('') setInput('')
setAttachments([]) setAttachments([])
setSlashMenuOpen(false) setSlashMenuOpen(false)
@ -494,7 +499,7 @@ export function ChatInput() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ModelSelector /> <ModelSelector />
<button <button
onClick={isActive ? stopGeneration : handleSubmit} onClick={isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isActive && !canSubmit} disabled={!isActive && !canSubmit}
title={isActive ? t('chat.stopTitle') : undefined} 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 ${ 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 { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MessageList, buildRenderItems } from './MessageList' import { MessageList, buildRenderItems } from './MessageList'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import type { UIMessage } from '../../types/chat' 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', () => { describe('MessageList nested tool calls', () => {
beforeEach(() => { beforeEach(() => {
useChatStore.setState({ useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', status: 'idle' }] })
messages: [], useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
chatState: 'idle',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
elapsedSeconds: 0,
})
}) })
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => { it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
useChatStore.setState({ useChatStore.setState({
messages: [ sessions: {
{ [ACTIVE_TAB]: makeSessionState({
id: 'tool-agent', messages: [
type: 'tool_use', {
toolName: 'Agent', id: 'tool-agent',
toolUseId: 'agent-1', type: 'tool_use',
input: { description: 'Inspect src/components' }, toolName: 'Agent',
timestamp: 1, toolUseId: 'agent-1',
}, input: { description: 'Inspect src/components' },
{ timestamp: 1,
id: 'tool-read', },
type: 'tool_use', {
toolName: 'Read', id: 'tool-read',
toolUseId: 'read-1', type: 'tool_use',
input: { file_path: '/tmp/example.ts' }, toolName: 'Read',
timestamp: 2, toolUseId: 'read-1',
parentToolUseId: 'agent-1', input: { file_path: '/tmp/example.ts' },
}, timestamp: 2,
{ parentToolUseId: 'agent-1',
id: 'result-read', },
type: 'tool_result', {
toolUseId: 'read-1', id: 'result-read',
content: 'const answer = 42', type: 'tool_result',
isError: false, toolUseId: 'read-1',
timestamp: 3, content: 'const answer = 42',
parentToolUseId: 'agent-1', isError: false,
}, timestamp: 3,
], parentToolUseId: 'agent-1',
},
],
}),
},
}) })
const { container } = render(<MessageList />) 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', () => { it('shows failed agent status and compact unavailable summary for Explore launch errors', () => {
useChatStore.setState({ useChatStore.setState({
messages: [ sessions: {
{ [ACTIVE_TAB]: makeSessionState({
id: 'tool-agent', messages: [
type: 'tool_use', {
toolName: 'Agent', id: 'tool-agent',
toolUseId: 'agent-1', type: 'tool_use',
input: { description: '探索整体架构', subagent_type: 'Explore' }, toolName: 'Agent',
timestamp: 1, toolUseId: 'agent-1',
}, input: { description: '探索整体架构', subagent_type: 'Explore' },
{ timestamp: 1,
id: 'result-agent', },
type: 'tool_result', {
toolUseId: 'agent-1', id: 'result-agent',
content: `Agent type 'Explore' not found. Available agents: general-purpose`, type: 'tool_result',
isError: true, toolUseId: 'agent-1',
timestamp: 2, content: `Agent type 'Explore' not found. Available agents: general-purpose`,
}, isError: true,
], timestamp: 2,
},
],
}),
},
}) })
render(<MessageList />) render(<MessageList />)
@ -136,27 +159,31 @@ describe('MessageList nested tool calls', () => {
const longResult = '探索完成。让我将结果整合写入计划文件。第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。' const longResult = '探索完成。让我将结果整合写入计划文件。第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。'
useChatStore.setState({ useChatStore.setState({
messages: [ sessions: {
{ [ACTIVE_TAB]: makeSessionState({
id: 'tool-agent', messages: [
type: 'tool_use', {
toolName: 'Agent', id: 'tool-agent',
toolUseId: 'agent-1', type: 'tool_use',
input: { description: '探索整体架构' }, toolName: 'Agent',
timestamp: 1, toolUseId: 'agent-1',
}, input: { description: '探索整体架构' },
{ timestamp: 1,
id: 'result-agent', },
type: 'tool_result', {
toolUseId: 'agent-1', id: 'result-agent',
content: { type: 'tool_result',
status: 'completed', toolUseId: 'agent-1',
content: [{ type: 'text', text: longResult }], content: {
}, status: 'completed',
isError: false, content: [{ type: 'text', text: longResult }],
timestamp: 2, },
}, isError: false,
], timestamp: 2,
},
],
}),
},
}) })
render(<MessageList />) render(<MessageList />)
@ -180,26 +207,30 @@ describe('MessageList nested tool calls', () => {
}) })
useChatStore.setState({ useChatStore.setState({
messages: [ sessions: {
{ [ACTIVE_TAB]: makeSessionState({
id: 'user-1', messages: [
type: 'user_text', {
content: '请帮我探索整体架构', id: 'user-1',
timestamp: 1, type: 'user_text',
}, content: '请帮我探索整体架构',
{ timestamp: 1,
id: 'assistant-1', },
type: 'assistant_text', {
content: '先看 CLI 和服务端入口。', id: 'assistant-1',
timestamp: 2, type: 'assistant_text',
}, content: '先看 CLI 和服务端入口。',
{ timestamp: 2,
id: 'assistant-2', },
type: 'assistant_text', {
content: '再看 desktop 前后端边界。', id: 'assistant-2',
timestamp: 3, type: 'assistant_text',
}, content: '再看 desktop 前后端边界。',
], timestamp: 3,
},
],
}),
},
}) })
render(<MessageList />) render(<MessageList />)

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en' import type { TranslationKey } from '../../i18n/locales/en'
@ -10,7 +11,12 @@ function formatElapsed(seconds: number): string {
} }
export function StreamingIndicator() { 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() const t = useTranslation()
// Translate known server-sent verbs (e.g. "Thinking", "Task started") // 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 { ToolCallBlock } from './ToolCallBlock'
import { PermissionDialog } from './PermissionDialog' import { PermissionDialog } from './PermissionDialog'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
describe('chat blocks', () => { describe('chat blocks', () => {
beforeEach(() => { beforeEach(() => {
useChatStore.setState({ useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] })
chatState: 'idle', useChatStore.setState({ sessions: {} })
messages: [],
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
elapsedSeconds: 0,
})
}) })
it('keeps thinking collapsed by default', () => { it('keeps thinking collapsed by default', () => {
@ -72,13 +64,30 @@ describe('chat blocks', () => {
it('shows a diff preview for edit permission requests', () => { it('shows a diff preview for edit permission requests', () => {
useChatStore.setState({ useChatStore.setState({
pendingPermission: { sessions: {
requestId: 'perm-1', 'active-tab': {
toolName: 'Edit', messages: [],
input: { chatState: 'idle',
file_path: '/tmp/example.ts', connectionState: 'connected',
old_string: 'const count = 1', streamingText: '',
new_string: 'const count = 2', 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') expect(container.textContent).toContain('Allow')
// react-diff-viewer-continued uses styled-components tables that don't // react-diff-viewer-continued uses styled-components tables that don't
// fully render in jsdom, so we verify the DiffViewer wrapper is mounted // 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 { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { PermissionMode } from '../../types/settings' import type { PermissionMode } from '../../types/settings'
@ -26,6 +27,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
const t = useTranslation() const t = useTranslation()
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore() const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode) const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
const activeTabId = useTabStore((s) => s.activeTabId)
const sessions = useSessionStore((s) => s.sessions) const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId) const activeSessionId = useSessionStore((s) => s.activeSessionId)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -126,7 +128,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
onChange?.(item.value) onChange?.(item.value)
} else { } else {
void setPermissionMode(item.value) void setPermissionMode(item.value)
setSessionPermissionMode(item.value) if (activeTabId) setSessionPermissionMode(activeTabId, item.value)
} }
setOpen(false) setOpen(false)
}} }}
@ -208,7 +210,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
onChange?.('bypassPermissions') onChange?.('bypassPermissions')
} else { } else {
void setPermissionMode('bypassPermissions') void setPermissionMode('bypassPermissions')
setSessionPermissionMode('bypassPermissions') if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions')
} }
setConfirmDialog(false) setConfirmDialog(false)
}} }}

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { ProjectFilter } from './ProjectFilter' import { ProjectFilter } from './ProjectFilter'
import type { SessionListItem } from '../../types/session' 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) const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
@ -205,7 +207,11 @@ export function Sidebar() {
/> />
) : ( ) : (
<button <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)} onContextMenu={(e) => handleContextMenu(e, session.id)}
className={` 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 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 { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore' import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
export function StatusBar() { export function StatusBar() {
const { currentModel } = useSettingsStore() 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 { sessions, activeSessionId } = useSessionStore()
const t = useTranslation() 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 { useEffect } from 'react'
import { useSessionStore } from '../stores/sessionStore' import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore' import { useUIStore } from '../stores/uiStore'
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
@ -9,7 +10,8 @@ export function useKeyboardShortcuts() {
const closeModal = useUIStore((s) => s.closeModal) const closeModal = useUIStore((s) => s.closeModal)
const activeModal = useUIStore((s) => s.activeModal) const activeModal = useUIStore((s) => s.activeModal)
const stopGeneration = useChatStore((s) => s.stopGeneration) 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(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -38,14 +40,14 @@ export function useKeyboardShortcuts() {
// Cmd+. — Stop generation // Cmd+. — Stop generation
if (meta && e.key === '.') { if (meta && e.key === '.') {
if (chatState !== 'idle') { if (chatState !== 'idle' && activeTabId) {
e.preventDefault() e.preventDefault()
stopGeneration() stopGeneration(activeTabId)
} }
} }
} }
document.addEventListener('keydown', handler) document.addEventListener('keydown', handler)
return () => document.removeEventListener('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.Thinking': 'Thinking',
'serverVerb.Task started': 'Task started', 'serverVerb.Task started': 'Task started',
'serverVerb.Task in progress': 'Task in progress', '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 } as const
export type TranslationKey = keyof typeof en export type TranslationKey = keyof typeof en

View File

@ -436,4 +436,13 @@ export const zh: Record<TranslationKey, string> = {
'serverVerb.Thinking': '思考中', 'serverVerb.Thinking': '思考中',
'serverVerb.Task started': '任务已启动', 'serverVerb.Task started': '任务已启动',
'serverVerb.Task in progress': '任务进行中', '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 { useEffect, useMemo } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useSessionStore } from '../stores/sessionStore' import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { MessageList } from '../components/chat/MessageList' import { MessageList } from '../components/chat/MessageList'
@ -7,18 +8,20 @@ import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { SessionTaskBar } from '../components/chat/SessionTaskBar' import { SessionTaskBar } from '../components/chat/SessionTaskBar'
export function ActiveSession() { export function ActiveSession() {
const activeSessionId = useSessionStore((s) => s.activeSessionId) const activeTabId = useTabStore((s) => s.activeTabId)
const sessions = useSessionStore((s) => s.sessions) const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession) 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(() => { useEffect(() => {
if (activeSessionId) { if (activeTabId) {
connectToSession(activeSessionId) connectToSession(activeTabId)
} }
}, [activeSessionId, connectToSession]) }, [activeTabId, connectToSession])
const isActive = chatState !== 'idle' const isActive = chatState !== 'idle'
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
@ -32,6 +35,8 @@ export function ActiveSession() {
return `${Math.floor(diff / 86400000)}d ago` return `${Math.floor(diff / 86400000)}d ago`
}, [session?.modifiedAt]) }, [session?.modifiedAt])
if (!activeTabId) return null
return ( return (
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface"> <div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
{/* Session info header */} {/* Session info header */}

View File

@ -3,6 +3,7 @@ import { useTranslation } from '../i18n'
import { useSessionStore } from '../stores/sessionStore' import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useUIStore } from '../stores/uiStore' import { useUIStore } from '../stores/uiStore'
import { useTabStore } from '../stores/tabStore'
import { DirectoryPicker } from '../components/shared/DirectoryPicker' import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector' import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector' import { ModelSelector } from '../components/controls/ModelSelector'
@ -122,6 +123,7 @@ export function EmptySession() {
try { try {
const sessionId = await createSession(workDir || undefined) const sessionId = await createSession(workDir || undefined)
setActiveView('code') setActiveView('code')
useTabStore.getState().openTab(sessionId, 'New Session')
connectToSession(sessionId) connectToSession(sessionId)
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type, type: attachment.type,
@ -129,7 +131,7 @@ export function EmptySession() {
data: attachment.data, data: attachment.data,
mimeType: attachment.mimeType, mimeType: attachment.mimeType,
})) }))
sendMessage(text, attachmentPayload) sendMessage(sessionId, text, attachmentPayload)
setInput('') setInput('')
setAttachments([]) setAttachments([])
} catch (error) { } 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' import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore'
const TEST_SESSION_ID = 'test-session-1'
const initialState = useChatStore.getState() const initialState = useChatStore.getState()
describe('chatStore history mapping', () => { describe('chatStore history mapping', () => {
@ -41,19 +72,7 @@ describe('chatStore history mapping', () => {
sendMock.mockReset() sendMock.mockReset()
useChatStore.setState({ useChatStore.setState({
...initialState, ...initialState,
messages: [], sessions: {},
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: [],
}) })
}) })
@ -95,7 +114,29 @@ describe('chatStore history mapping', () => {
}) })
it('keeps parent tool linkage for live tool events', () => { 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', type: 'tool_use_complete',
toolName: 'Read', toolName: 'Read',
toolUseId: 'tool-1', toolUseId: 'tool-1',
@ -103,7 +144,7 @@ describe('chatStore history mapping', () => {
parentToolUseId: 'agent-1', parentToolUseId: 'agent-1',
}) })
useChatStore.getState().handleServerMessage({ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_result', type: 'tool_result',
toolUseId: 'tool-1', toolUseId: 'tool-1',
content: 'ok', content: 'ok',
@ -111,7 +152,7 @@ describe('chatStore history mapping', () => {
parentToolUseId: 'agent-1', parentToolUseId: 'agent-1',
}) })
expect(useChatStore.getState().messages).toMatchObject([ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{ {
type: 'tool_use', type: 'tool_use',
toolUseId: 'tool-1', toolUseId: 'tool-1',
@ -126,13 +167,32 @@ describe('chatStore history mapping', () => {
}) })
it('sends permission mode updates to the active session only', () => { it('sends permission mode updates to the active session only', () => {
useChatStore.getState().setSessionPermissionMode('acceptEdits') useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits')
expect(sendMock).not.toHaveBeenCalled() expect(sendMock).not.toHaveBeenCalled()
useChatStore.setState({ connectedSessionId: 'session-1' }) useChatStore.setState({
useChatStore.getState().setSessionPermissionMode('acceptEdits') 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', type: 'set_permission_mode',
mode: 'acceptEdits', mode: 'acceptEdits',
}) })

View File

@ -4,6 +4,7 @@ import { sessionsApi } from '../api/sessions'
import { useTeamStore } from './teamStore' import { useTeamStore } from './teamStore'
import { useSessionStore } from './sessionStore' import { useSessionStore } from './sessionStore'
import { useCLITaskStore } from './cliTaskStore' import { useCLITaskStore } from './cliTaskStore'
import { useTabStore } from './tabStore'
import { randomSpinnerVerb } from '../config/spinnerVerbs' import { randomSpinnerVerb } from '../config/spinnerVerbs'
import type { MessageEntry } from '../types/session' import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings' import type { PermissionMode } from '../types/settings'
@ -11,7 +12,7 @@ import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage,
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
type ChatStore = { export type PerSessionState = {
messages: UIMessage[] messages: UIMessage[]
chatState: ChatState chatState: ChatState
connectionState: ConnectionState connectionState: ConnectionState
@ -29,31 +30,11 @@ type ChatStore = {
tokenUsage: TokenUsage tokenUsage: TokenUsage
elapsedSeconds: number elapsedSeconds: number
statusVerb: string statusVerb: string
connectedSessionId: string | null
slashCommands: Array<{ name: string; description: string }> slashCommands: Array<{ name: string; description: string }>
elapsedTimer: ReturnType<typeof setInterval> | null
// 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
} }
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) const DEFAULT_SESSION_STATE: PerSessionState = {
/** 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) => ({
messages: [], messages: [],
chatState: 'idle', chatState: 'idle',
connectionState: 'disconnected', connectionState: 'disconnected',
@ -66,97 +47,127 @@ export const useChatStore = create<ChatStore>((set, get) => ({
tokenUsage: { input_tokens: 0, output_tokens: 0 }, tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0, elapsedSeconds: 0,
statusVerb: '', statusVerb: '',
connectedSessionId: null,
slashCommands: [], slashCommands: [],
elapsedTimer: null,
}
connectToSession: (sessionId: string) => { function createDefaultSessionState(): PerSessionState {
const current = get().connectedSessionId return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } }
if (current === sessionId) return }
// Disconnect previous type ChatStore = {
if (current) wsManager.disconnect() sessions: Record<string, PerSessionState>
set({ getSession: (sessionId: string) => PerSessionState
connectedSessionId: sessionId, connectToSession: (sessionId: string) => void
connectionState: 'connecting', disconnectSession: (sessionId: string) => void
messages: [], sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
chatState: 'idle', respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void
streamingText: '', setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
streamingToolInput: '', stopGeneration: (sessionId: string) => void
activeToolUseId: null, loadHistory: (sessionId: string) => Promise<void>
activeToolName: null, clearMessages: (sessionId: string) => void
activeThinkingId: null, handleServerMessage: (sessionId: string, msg: ServerMessage) => void
pendingPermission: null, }
elapsedSeconds: 0,
slashCommands: [],
})
// Clear all previous handlers before registering new ones const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
// This prevents handler accumulation causing message duplication const pendingTaskToolUseIds = new Set<string>()
wsManager.clearHandlers()
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.connect(sessionId)
wsManager.onMessage((msg) => { wsManager.onMessage(sessionId, (msg) => {
if (msg.type === 'connected') { 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) get().loadHistory(sessionId)
useCLITaskStore.getState().fetchSessionTasks(sessionId) useCLITaskStore.getState().fetchSessionTasks(sessionId)
sessionsApi.getSlashCommands(sessionId) sessionsApi.getSlashCommands(sessionId)
.then(({ commands }) => { .then(({ commands }) => {
if (get().connectedSessionId === sessionId) { if (get().sessions[sessionId]) {
set({ slashCommands: commands }) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) }))
} }
}) })
.catch(() => { .catch(() => {
if (get().connectedSessionId === sessionId) { if (get().sessions[sessionId]) {
set({ slashCommands: [] }) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) }))
} }
}) })
}, },
disconnectSession: () => { disconnectSession: (sessionId) => {
wsManager.disconnect() const session = get().sessions[sessionId]
if (elapsedTimer) clearInterval(elapsedTimer) if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
useCLITaskStore.getState().clearTasks() wsManager.disconnect(sessionId)
set({ connectedSessionId: null, chatState: 'idle' }) set((s) => {
const { [sessionId]: _, ...rest } = s.sessions
return { sessions: rest }
})
}, },
sendMessage: (content: string, attachments?: AttachmentRef[]) => { sendMessage: (sessionId, content, attachments?) => {
const userFacingContent = content.trim() const userFacingContent = content.trim()
const uiAttachments: UIAttachment[] | undefined = const uiAttachments: UIAttachment[] | undefined =
attachments && attachments.length > 0 attachments && attachments.length > 0
? attachments.map((attachment) => ({ ? attachments.map((a) => ({
type: attachment.type, type: a.type,
name: attachment.name || attachment.path || attachment.mimeType || attachment.type, name: a.name || a.path || a.mimeType || a.type,
data: attachment.data, data: a.data,
mimeType: attachment.mimeType, mimeType: a.mimeType,
})) }))
: undefined : undefined
// If all tasks are completed, inline the task summary before the new user message
const taskStore = useCLITaskStore.getState() const taskStore = useCLITaskStore.getState()
const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed') 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) => { set((s) => {
const newMessages = [...s.messages] const session = s.sessions[sessionId]
if (!session) return s
const newMessages = [...session.messages]
if (allTasksDone) { if (allTasksDone) {
newMessages.push({ newMessages.push({
id: nextId(), id: nextId(),
type: 'task_summary', type: 'task_summary',
tasks: taskStore.tasks.map((t) => ({ tasks: taskStore.tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })),
id: t.id,
subject: t.subject,
status: t.status,
activeForm: t.activeForm,
})),
timestamp: Date.now(), timestamp: Date.now(),
}) })
// Clear sticky task bar since we inlined the summary
taskStore.clearTasks() taskStore.clearTasks()
} }
newMessages.push({ newMessages.push({
@ -166,64 +177,66 @@ export const useChatStore = create<ChatStore>((set, get) => ({
attachments: uiAttachments, attachments: uiAttachments,
timestamp: Date.now(), 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 { return {
messages: newMessages, sessions: {
chatState: 'thinking', ...s.sessions,
elapsedSeconds: 0, [sessionId]: {
streamingText: '', ...session,
statusVerb: randomSpinnerVerb(), messages: newMessages,
chatState: 'thinking',
elapsedSeconds: 0,
streamingText: '',
statusVerb: randomSpinnerVerb(),
elapsedTimer: timer,
},
},
} }
}) })
// Start elapsed timer wsManager.send(sessionId, { type: 'user_message', content, attachments })
if (elapsedTimer) clearInterval(elapsedTimer)
elapsedTimer = setInterval(() => {
set((s) => ({ elapsedSeconds: s.elapsedSeconds + 1 }))
}, 1000)
wsManager.send({ type: 'user_message', content, attachments })
}, },
respondToPermission: (requestId: string, allowed: boolean, rule?: string) => { respondToPermission: (sessionId, requestId, allowed, rule?) => {
wsManager.send({ type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) }) wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
set({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' }) set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
}, },
setSessionPermissionMode: (mode) => { setSessionPermissionMode: (sessionId, mode) => {
if (!get().connectedSessionId) return if (!get().sessions[sessionId]) return
wsManager.send({ type: 'set_permission_mode', mode }) wsManager.send(sessionId, { type: 'set_permission_mode', mode })
}, },
stopGeneration: () => { stopGeneration: (sessionId) => {
wsManager.send({ type: 'stop_generation' }) wsManager.send(sessionId, { type: 'stop_generation' })
if (elapsedTimer) clearInterval(elapsedTimer) set((s) => {
set({ chatState: 'idle' }) 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 { try {
const { messages } = await sessionsApi.getMessages(sessionId) const { messages } = await sessionsApi.getMessages(sessionId)
const uiMessages = mapHistoryMessagesToUiMessages(messages) const uiMessages = mapHistoryMessagesToUiMessages(messages)
set((state) => { set((state) => {
if (state.connectedSessionId !== sessionId || state.messages.length > 0) { const session = state.sessions[sessionId]
return state if (!session || session.messages.length > 0) return state
} return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages })) }
return { ...state, messages: uiMessages }
}) })
// Extract the last TodoWrite input from history so TaskBar shows for V1 sessions
const lastTodos = extractLastTodoWriteFromHistory(messages) const lastTodos = extractLastTodoWriteFromHistory(messages)
if (lastTodos && lastTodos.length > 0) { if (lastTodos && lastTodos.length > 0) {
const taskStore = useCLITaskStore.getState() 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)) { if (hasUserMessagesAfterTaskCompletion(messages)) {
useCLITaskStore.getState().markCompletedAndDismissed() 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) { switch (msg.type) {
case 'connected': case 'connected':
break break
case 'status': case 'status':
set({ update((session) => ({
chatState: msg.state, 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.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: '' } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
}) }))
if (msg.state === 'idle' && elapsedTimer) { if (msg.state === 'idle') {
clearInterval(elapsedTimer) const session = get().sessions[sessionId]
elapsedTimer = null if (session?.elapsedTimer) {
clearInterval(session.elapsedTimer)
update(() => ({ elapsedTimer: null }))
}
} }
// Sync tab status
useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
break break
case 'content_start': { case 'content_start': {
// Flush any accumulated streamingText as assistant_text BEFORE const session = get().sessions[sessionId]
// switching to the next block. This preserves intermediate text if (!session) break
// segments between tool calls (e.g. "项目已经有 node_modules直接启动"). const pendingText = session.streamingText.trim()
const pendingText = get().streamingText.trim()
if (pendingText) { if (pendingText) {
set((s) => ({ update((s) => ({
messages: [...s.messages, { messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }],
id: nextId(),
type: 'assistant_text',
content: pendingText,
timestamp: Date.now(),
}],
streamingText: '', streamingText: '',
})) }))
} }
if (msg.blockType === 'text') { if (msg.blockType === 'text') {
set({ streamingText: '', chatState: 'streaming', activeThinkingId: null }) update(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null }))
} else if (msg.blockType === 'tool_use') { } else if (msg.blockType === 'tool_use') {
set({ update(() => ({
activeToolUseId: msg.toolUseId ?? null, activeToolUseId: msg.toolUseId ?? null,
activeToolName: msg.toolName ?? null, activeToolName: msg.toolName ?? null,
streamingToolInput: '', streamingToolInput: '',
chatState: 'tool_executing', chatState: 'tool_executing',
activeThinkingId: null, activeThinkingId: null,
}) }))
} }
break break
} }
case 'content_delta': case 'content_delta':
if (msg.text !== undefined) { if (msg.text !== undefined) update((s) => ({ streamingText: s.streamingText + msg.text }))
set((s) => ({ streamingText: s.streamingText + msg.text })) if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
}
if (msg.toolInput !== undefined) {
set((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
}
break break
case 'thinking': case 'thinking':
// Merge consecutive thinking deltas into one message. update((s) => {
// 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) => {
const pendingText = s.streamingText.trim() const pendingText = s.streamingText.trim()
const base = pendingText const base = pendingText
? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }] ? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }]
: s.messages : s.messages
const last = base[base.length - 1] const last = base[base.length - 1]
if (last && last.type === 'thinking') { if (last && last.type === 'thinking') {
// Append to existing thinking message
const updated = [...base] const updated = [...base]
updated[updated.length - 1] = { ...last, content: last.content + msg.text } updated[updated.length - 1] = { ...last, content: last.content + msg.text }
return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' } return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' }
} }
const id = nextId() const id = nextId()
// Create new thinking message
return { return {
messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }],
chatState: 'thinking', chatState: 'thinking',
@ -325,49 +328,33 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break break
case 'tool_use_complete': { case 'tool_use_complete': {
const toolName = msg.toolName || get().activeToolName || 'unknown' const session = get().sessions[sessionId]
set((s) => ({ const toolName = msg.toolName || session?.activeToolName || 'unknown'
update((s) => ({
messages: [...s.messages, { messages: [...s.messages, {
id: nextId(), id: nextId(), type: 'tool_use', toolName,
type: 'tool_use',
toolName,
toolUseId: msg.toolUseId || s.activeToolUseId || '', toolUseId: msg.toolUseId || s.activeToolUseId || '',
input: msg.input, input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
timestamp: Date.now(),
parentToolUseId: msg.parentToolUseId,
}], }],
activeToolUseId: null, activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '',
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)) { if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) {
useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos) useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos)
} else if (TASK_TOOL_NAMES.has(toolName)) { } else if (TASK_TOOL_NAMES.has(toolName)) {
// V2 task tools — refresh will happen on tool_result const useId = msg.toolUseId || session?.activeToolUseId
// when the tool has actually finished executing and written to disk
const useId = msg.toolUseId || get().activeToolUseId
if (useId) pendingTaskToolUseIds.add(useId) if (useId) pendingTaskToolUseIds.add(useId)
} }
break break
} }
case 'tool_result': case 'tool_result':
set((s) => ({ update((s) => ({
messages: [...s.messages, { messages: [...s.messages, {
id: nextId(), id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId,
type: 'tool_result', content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
toolUseId: msg.toolUseId,
content: msg.content,
isError: msg.isError,
timestamp: Date.now(),
parentToolUseId: msg.parentToolUseId,
}], }],
chatState: 'thinking', chatState: 'thinking', activeThinkingId: null,
activeThinkingId: null,
})) }))
// Refresh tasks after a task tool has finished executing
if (pendingTaskToolUseIds.has(msg.toolUseId)) { if (pendingTaskToolUseIds.has(msg.toolUseId)) {
pendingTaskToolUseIds.delete(msg.toolUseId) pendingTaskToolUseIds.delete(msg.toolUseId)
useCLITaskStore.getState().refreshTasks() useCLITaskStore.getState().refreshTasks()
@ -375,230 +362,118 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break break
case 'permission_request': case 'permission_request':
set({ update((s) => ({
pendingPermission: { pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description },
requestId: msg.requestId,
toolName: msg.toolName,
input: msg.input,
description: msg.description,
},
chatState: 'permission_pending', chatState: 'permission_pending',
activeThinkingId: null, activeThinkingId: null,
})
set((s) => ({
messages: [...s.messages, { messages: [...s.messages, {
id: nextId(), id: nextId(), type: 'permission_request', requestId: msg.requestId,
type: 'permission_request', toolName: msg.toolName, input: msg.input, description: msg.description, timestamp: Date.now(),
requestId: msg.requestId,
toolName: msg.toolName,
input: msg.input,
description: msg.description,
timestamp: Date.now(),
}], }],
})) }))
break break
case 'message_complete': { case 'message_complete': {
// Flush streaming text as a message const session = get().sessions[sessionId]
const text = get().streamingText if (!session) break
const text = session.streamingText
if (text) { if (text) {
set((s) => ({ update((s) => ({
messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }], messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }],
streamingText: '', streamingText: '',
})) }))
} }
set({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null }) if (session.elapsedTimer) clearInterval(session.elapsedTimer)
if (elapsedTimer) { update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null }))
clearInterval(elapsedTimer)
elapsedTimer = null
}
break break
} }
case 'error': case 'error':
set((s) => ({ update((s) => ({
messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }], messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }],
chatState: 'idle', chatState: 'idle', activeThinkingId: null,
activeThinkingId: null,
})) }))
if (elapsedTimer) { useTabStore.getState().updateTabStatus(sessionId, 'error')
clearInterval(elapsedTimer) {
elapsedTimer = null const session = get().sessions[sessionId]
if (session?.elapsedTimer) {
clearInterval(session.elapsedTimer)
update(() => ({ elapsedTimer: null }))
}
} }
break break
case 'team_created': case 'team_created':
useTeamStore.getState().handleTeamCreated(msg.teamName) useTeamStore.getState().handleTeamCreated(msg.teamName)
break break
case 'team_update': case 'team_update':
useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members) useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members)
break break
case 'team_deleted': case 'team_deleted':
useTeamStore.getState().handleTeamDeleted(msg.teamName) useTeamStore.getState().handleTeamDeleted(msg.teamName)
break break
case 'task_update': case 'task_update':
break break
case 'session_title_updated': case 'session_title_updated':
useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title) useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title)
useTabStore.getState().updateTabTitle(msg.sessionId, msg.title)
break break
case 'system_notification': case 'system_notification':
// Cache slash commands from CLI init
if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) { 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 break
case 'pong': case 'pong':
break break
} }
}, },
})) }))
type AssistantHistoryBlock = { // ─── History mapping helpers (unchanged from original) ─────────
type: string
text?: string
thinking?: string
name?: string
id?: string
input?: unknown
}
type UserHistoryBlock = { type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
type: string 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 }
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[] { export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] {
const uiMessages: UIMessage[] = [] const uiMessages: UIMessage[] = []
for (const msg of messages) { for (const msg of messages) {
const timestamp = new Date(msg.timestamp).getTime() const timestamp = new Date(msg.timestamp).getTime()
if (msg.type === 'user' && typeof msg.content === 'string') { if (msg.type === 'user' && typeof msg.content === 'string') {
uiMessages.push({ uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
id: msg.id || nextId(),
type: 'user_text',
content: msg.content,
timestamp,
})
continue continue
} }
if (msg.type === 'assistant' && typeof msg.content === 'string') { if (msg.type === 'assistant' && typeof msg.content === 'string') {
uiMessages.push({ uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model })
id: msg.id || nextId(),
type: 'assistant_text',
content: msg.content,
timestamp,
model: msg.model,
})
continue 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)) { if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
for (const block of msg.content as AssistantHistoryBlock[]) { for (const block of msg.content as AssistantHistoryBlock[]) {
if (block.type === 'thinking' && block.thinking) { if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
uiMessages.push({ else if (block.type === 'text' && block.text) uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model })
id: nextId(), 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 })
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 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)) { if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) {
const textParts: string[] = [] const textParts: string[] = []
const attachments: UIAttachment[] = [] const attachments: UIAttachment[] = []
for (const block of msg.content as UserHistoryBlock[]) { for (const block of msg.content as UserHistoryBlock[]) {
if (block.type === 'text' && block.text) { if (block.type === 'text' && block.text) textParts.push(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 === 'image') { else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' })
attachments.push({ 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 })
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) { if (textParts.length > 0 || attachments.length > 0) {
uiMessages.push({ uiMessages.push({ id: nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp })
id: nextId(),
type: 'user_text',
content: textParts.join('\n'),
attachments: attachments.length > 0 ? attachments : undefined,
timestamp,
})
} }
} }
} }
return uiMessages return uiMessages
} }
/** Scan history messages for the last TodoWrite tool_use and return the todos array. function extractLastTodoWriteFromHistory(messages: MessageEntry[]): Array<{ content: string; status: string; activeForm?: string }> | null {
* 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 {
let foundIndex = -1 let foundIndex = -1
let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null 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--) { for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]! const msg = messages[i]!
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
@ -617,45 +492,28 @@ function extractLastTodoWriteFromHistory(
if (todos) break if (todos) break
} }
} }
if (!todos) return null 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') const allDone = todos.every((t) => t.status === 'completed')
if (allDone) { if (allDone) {
for (let i = foundIndex + 1; i < messages.length; i++) { for (let i = foundIndex + 1; i < messages.length; i++) {
const msg = messages[i]! if (messages[i]!.type === 'user' && messages[i]!.content) return null
if (msg.type === 'user' && msg.content) return null
} }
} }
return todos return todos
} }
const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) 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 { function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean {
// Find the index of the last task-related tool call
let lastTaskIndex = -1 let lastTaskIndex = -1
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]! const msg = messages[i]!
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
const blocks = msg.content as AssistantHistoryBlock[] const blocks = msg.content as AssistantHistoryBlock[]
if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break }
lastTaskIndex = i
break
}
} }
} }
if (lastTaskIndex < 0) return false if (lastTaskIndex < 0) return false
for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true }
// 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
}
return false 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 滚动