mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
commit
db19491fc8
1266
desktop/package-lock.json
generated
1266
desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
|
||||
@ -12,8 +12,10 @@ import { ToolInspection } from '../pages/ToolInspection'
|
||||
|
||||
// Layout components (chrome is now here, not in pages)
|
||||
import { AppShell } from '../components/layout/AppShell'
|
||||
import { Sidebar } from '../components/layout/Sidebar'
|
||||
import { UserMessage } from '../components/chat/UserMessage'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
/**
|
||||
* Core rendering tests: content-only pages must render without crashing
|
||||
@ -35,21 +37,66 @@ describe('Content-only pages render without errors', () => {
|
||||
})
|
||||
|
||||
it('ActiveSession renders with chat components', () => {
|
||||
const SESSION_ID = 'test-active-session'
|
||||
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', status: 'idle' }], activeTabId: SESSION_ID })
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { container } = render(<ActiveSession />)
|
||||
// Should have the title area and chat input
|
||||
expect(container.innerHTML).toContain('Untitled Session')
|
||||
// ChatInput has a textarea
|
||||
expect(container.querySelector('textarea')).toBeInTheDocument()
|
||||
expect(container.innerHTML).not.toContain('Preview')
|
||||
// Cleanup
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('ActiveSession shows a single primary action button while a turn is active', () => {
|
||||
useChatStore.setState({ chatState: 'thinking' })
|
||||
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] })
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'active-tab': {
|
||||
messages: [],
|
||||
chatState: 'thinking',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
render(<ActiveSession />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument()
|
||||
useChatStore.setState({ chatState: 'idle' })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('AgentTeams renders team strip and members', () => {
|
||||
@ -102,11 +149,11 @@ describe('Chat attachments', () => {
|
||||
|
||||
describe('AppShell layout renders chrome', () => {
|
||||
it('AppShell renders sidebar and session shell', () => {
|
||||
const { container } = render(<AppShell />)
|
||||
const { container } = render(<Sidebar />)
|
||||
expect(container.querySelector('aside')).toBeInTheDocument()
|
||||
expect(container.innerHTML).toContain('New session')
|
||||
expect(container.innerHTML).toContain('Scheduled')
|
||||
expect(container.innerHTML).toContain('Select a project')
|
||||
expect(container.innerHTML).toContain('All projects')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Button } from '../shared/Button'
|
||||
|
||||
@ -47,6 +48,7 @@ function parseInput(input: unknown): Question[] {
|
||||
|
||||
export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
|
||||
const { sendMessage } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const t = useTranslation()
|
||||
const questions = parseInput(input)
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
@ -82,7 +84,8 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
|
||||
if (!response) return
|
||||
|
||||
setSubmitted(true)
|
||||
sendMessage(response)
|
||||
if (!activeTabId) return
|
||||
sendMessage(activeTabId, response)
|
||||
}
|
||||
|
||||
// All questions must be answered (via selection or free text) to enable submit
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
|
||||
@ -43,7 +44,11 @@ export function ChatInput() {
|
||||
const slashMenuRef = useRef<HTMLDivElement>(null)
|
||||
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
|
||||
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const { sendMessage, chatState, stopGeneration, slashCommands } = useChatStore()
|
||||
const { sendMessage, stopGeneration } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const slashCommands = sessionState?.slashCommands ?? []
|
||||
const activeSessionId = useSessionStore((state) => state.activeSessionId)
|
||||
const activeSession = useSessionStore((state) => state.sessions.find((session) => session.id === state.activeSessionId) ?? null)
|
||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
||||
@ -213,7 +218,7 @@ export function ChatInput() {
|
||||
mimeType: attachment.mimeType,
|
||||
}))
|
||||
|
||||
sendMessage(text, attachmentPayload)
|
||||
sendMessage(activeTabId!, text, attachmentPayload)
|
||||
setInput('')
|
||||
setAttachments([])
|
||||
setSlashMenuOpen(false)
|
||||
@ -494,7 +499,7 @@ export function ChatInput() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector />
|
||||
<button
|
||||
onClick={isActive ? stopGeneration : handleSubmit}
|
||||
onClick={isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
|
||||
disabled={!isActive && !canSubmit}
|
||||
title={isActive ? t('chat.stopTitle') : undefined}
|
||||
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30 ${
|
||||
|
||||
@ -2,53 +2,72 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MessageList, buildRenderItems } from './MessageList'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import type { UIMessage } from '../../types/chat'
|
||||
import type { PerSessionState } from '../../stores/chatStore'
|
||||
|
||||
const ACTIVE_TAB = 'active-tab'
|
||||
|
||||
function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionState {
|
||||
return {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
elapsedTimer: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageList nested tool calls', () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
elapsedSeconds: 0,
|
||||
})
|
||||
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', status: 'idle' }] })
|
||||
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
|
||||
})
|
||||
|
||||
it('renders sub-agent tool calls inline beneath the parent agent tool call', () => {
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-1',
|
||||
input: { description: 'Inspect src/components' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'tool-read',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-1',
|
||||
input: { file_path: '/tmp/example.ts' },
|
||||
timestamp: 2,
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
{
|
||||
id: 'result-read',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'read-1',
|
||||
content: 'const answer = 42',
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
],
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-1',
|
||||
input: { description: 'Inspect src/components' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'tool-read',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-1',
|
||||
input: { file_path: '/tmp/example.ts' },
|
||||
timestamp: 2,
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
{
|
||||
id: 'result-read',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'read-1',
|
||||
content: 'const answer = 42',
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<MessageList />)
|
||||
@ -106,24 +125,28 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
it('shows failed agent status and compact unavailable summary for Explore launch errors', () => {
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-1',
|
||||
input: { description: '探索整体架构', subagent_type: 'Explore' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'result-agent',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'agent-1',
|
||||
content: `Agent type 'Explore' not found. Available agents: general-purpose`,
|
||||
isError: true,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-1',
|
||||
input: { description: '探索整体架构', subagent_type: 'Explore' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'result-agent',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'agent-1',
|
||||
content: `Agent type 'Explore' not found. Available agents: general-purpose`,
|
||||
isError: true,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
@ -136,27 +159,31 @@ describe('MessageList nested tool calls', () => {
|
||||
const longResult = '探索完成。让我将结果整合写入计划文件。第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。'
|
||||
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-1',
|
||||
input: { description: '探索整体架构' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'result-agent',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'agent-1',
|
||||
content: {
|
||||
status: 'completed',
|
||||
content: [{ type: 'text', text: longResult }],
|
||||
},
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-1',
|
||||
input: { description: '探索整体架构' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'result-agent',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'agent-1',
|
||||
content: {
|
||||
status: 'completed',
|
||||
content: [{ type: 'text', text: longResult }],
|
||||
},
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
@ -180,26 +207,30 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '请帮我探索整体架构',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: '先看 CLI 和服务端入口。',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: '再看 desktop 前后端边界。',
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '请帮我探索整体架构',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: '先看 CLI 和服务端入口。',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: '再看 desktop 前后端边界。',
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n/locales/en'
|
||||
import { UserMessage } from './UserMessage'
|
||||
@ -63,7 +64,12 @@ export function buildRenderItems(messages: UIMessage[], toolUseIds: Set<string>)
|
||||
}
|
||||
|
||||
export function MessageList() {
|
||||
const { messages, chatState, streamingText, activeThinkingId } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const messages = sessionState?.messages ?? []
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const streamingText = sessionState?.streamingText ?? ''
|
||||
const activeThinkingId = sessionState?.activeThinkingId ?? null
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Button } from '../shared/Button'
|
||||
import { DiffViewer } from './DiffViewer'
|
||||
@ -110,7 +111,9 @@ function renderPermissionPreview(toolName: string, input: unknown) {
|
||||
}
|
||||
|
||||
export function PermissionDialog({ requestId, toolName, input, description }: Props) {
|
||||
const { respondToPermission, pendingPermission } = useChatStore()
|
||||
const { respondToPermission } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const pendingPermission = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.pendingPermission : undefined)
|
||||
const t = useTranslation()
|
||||
const isPending = pendingPermission?.requestId === requestId
|
||||
const [showRaw, setShowRaw] = useState(false)
|
||||
@ -223,7 +226,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => respondToPermission(requestId, true)}
|
||||
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, true)}
|
||||
icon={
|
||||
<span className="material-symbols-outlined text-[14px]">check</span>
|
||||
}
|
||||
@ -233,7 +236,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => respondToPermission(requestId, true, 'always')}
|
||||
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, true, 'always')}
|
||||
icon={
|
||||
<span className="material-symbols-outlined text-[14px]">verified</span>
|
||||
}
|
||||
@ -244,7 +247,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => respondToPermission(requestId, false)}
|
||||
onClick={() => activeTabId && respondToPermission(activeTabId, requestId, false)}
|
||||
icon={
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n/locales/en'
|
||||
|
||||
@ -10,7 +11,12 @@ function formatElapsed(seconds: number): string {
|
||||
}
|
||||
|
||||
export function StreamingIndicator() {
|
||||
const { chatState, statusVerb, elapsedSeconds, tokenUsage } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const statusVerb = sessionState?.statusVerb ?? ''
|
||||
const elapsedSeconds = sessionState?.elapsedSeconds ?? 0
|
||||
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
|
||||
const t = useTranslation()
|
||||
|
||||
// Translate known server-sent verbs (e.g. "Thinking", "Task started")
|
||||
|
||||
@ -4,20 +4,12 @@ import { ThinkingBlock } from './ThinkingBlock'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { PermissionDialog } from './PermissionDialog'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
describe('chat blocks', () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({
|
||||
chatState: 'idle',
|
||||
messages: [],
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
elapsedSeconds: 0,
|
||||
})
|
||||
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('keeps thinking collapsed by default', () => {
|
||||
@ -72,13 +64,30 @@ describe('chat blocks', () => {
|
||||
|
||||
it('shows a diff preview for edit permission requests', () => {
|
||||
useChatStore.setState({
|
||||
pendingPermission: {
|
||||
requestId: 'perm-1',
|
||||
toolName: 'Edit',
|
||||
input: {
|
||||
file_path: '/tmp/example.ts',
|
||||
old_string: 'const count = 1',
|
||||
new_string: 'const count = 2',
|
||||
sessions: {
|
||||
'active-tab': {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: {
|
||||
requestId: 'perm-1',
|
||||
toolName: 'Edit',
|
||||
input: {
|
||||
file_path: '/tmp/example.ts',
|
||||
old_string: 'const count = 1',
|
||||
new_string: 'const count = 2',
|
||||
},
|
||||
},
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -99,6 +108,6 @@ describe('chat blocks', () => {
|
||||
expect(container.textContent).toContain('Allow')
|
||||
// react-diff-viewer-continued uses styled-components tables that don't
|
||||
// fully render in jsdom, so we verify the DiffViewer wrapper is mounted
|
||||
expect(container.querySelector('[class*="rounded-lg"]')).toBeTruthy()
|
||||
expect(container.querySelector('[class*="rounded-[var(--radius-lg)]"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { PermissionMode } from '../../types/settings'
|
||||
|
||||
@ -26,6 +27,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
|
||||
const t = useTranslation()
|
||||
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
|
||||
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -126,7 +128,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
|
||||
onChange?.(item.value)
|
||||
} else {
|
||||
void setPermissionMode(item.value)
|
||||
setSessionPermissionMode(item.value)
|
||||
if (activeTabId) setSessionPermissionMode(activeTabId, item.value)
|
||||
}
|
||||
setOpen(false)
|
||||
}}
|
||||
@ -208,7 +210,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
|
||||
onChange?.('bypassPermissions')
|
||||
} else {
|
||||
void setPermissionMode('bypassPermissions')
|
||||
setSessionPermissionMode('bypassPermissions')
|
||||
if (activeTabId) setSessionPermissionMode(activeTabId, 'bypassPermissions')
|
||||
}
|
||||
setConfirmDialog(false)
|
||||
}}
|
||||
|
||||
@ -5,6 +5,9 @@ import { ToastContainer } from '../shared/Toast'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
|
||||
import { TabBar } from './TabBar'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||
|
||||
@ -21,6 +24,12 @@ export function AppShell() {
|
||||
try {
|
||||
await initializeDesktopServerUrl()
|
||||
await fetchSettings()
|
||||
// Restore tabs from localStorage
|
||||
await useTabStore.getState().restoreTabs()
|
||||
const activeId = useTabStore.getState().activeTabId
|
||||
if (activeId) {
|
||||
useChatStore.getState().connectToSession(activeId)
|
||||
}
|
||||
if (!cancelled) {
|
||||
setReady(true)
|
||||
}
|
||||
@ -92,6 +101,7 @@ export function AppShell() {
|
||||
)}
|
||||
<Sidebar />
|
||||
<main id="content-area" className={`flex-1 flex flex-col overflow-hidden relative ${isTauri ? 'pt-[38px]' : ''}`}>
|
||||
<TabBar />
|
||||
<ContentRouter />
|
||||
</main>
|
||||
<ToastContainer />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { EmptySession } from '../../pages/EmptySession'
|
||||
import { ActiveSession } from '../../pages/ActiveSession'
|
||||
@ -9,7 +9,7 @@ import { AgentTranscript } from '../../pages/AgentTranscript'
|
||||
|
||||
export function ContentRouter() {
|
||||
const activeView = useUIStore((s) => s.activeView)
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const viewingAgentId = useTeamStore((s) => s.viewingAgentId)
|
||||
|
||||
if (activeView === 'settings') {
|
||||
@ -33,7 +33,7 @@ export function ContentRouter() {
|
||||
}
|
||||
|
||||
// Code view
|
||||
if (!activeSessionId) {
|
||||
if (!activeTabId) {
|
||||
return <EmptySession />
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@ import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ProjectFilter } from './ProjectFilter'
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||
|
||||
@ -205,7 +207,11 @@ export function Sidebar() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setActiveView('code'); setActiveSession(session.id) }}
|
||||
onClick={() => {
|
||||
setActiveView('code')
|
||||
useTabStore.getState().openTab(session.id, session.title)
|
||||
useChatStore.getState().connectToSession(session.id)
|
||||
}}
|
||||
onContextMenu={(e) => handleContextMenu(e, session.id)}
|
||||
className={`
|
||||
w-full flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm text-left rounded-[var(--radius-md)] transition-colors duration-200 group
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function StatusBar() {
|
||||
const { currentModel } = useSettingsStore()
|
||||
const { connectionState } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const connectionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.connectionState ?? 'disconnected' : 'disconnected')
|
||||
const { sessions, activeSessionId } = useSessionStore()
|
||||
const t = useTranslation()
|
||||
|
||||
|
||||
220
desktop/src/components/layout/TabBar.tsx
Normal file
220
desktop/src/components/layout/TabBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
@ -9,7 +10,8 @@ export function useKeyboardShortcuts() {
|
||||
const closeModal = useUIStore((s) => s.closeModal)
|
||||
const activeModal = useUIStore((s) => s.activeModal)
|
||||
const stopGeneration = useChatStore((s) => s.stopGeneration)
|
||||
const chatState = useChatStore((s) => s.chatState)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle')
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@ -38,14 +40,14 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
// Cmd+. — Stop generation
|
||||
if (meta && e.key === '.') {
|
||||
if (chatState !== 'idle') {
|
||||
if (chatState !== 'idle' && activeTabId) {
|
||||
e.preventDefault()
|
||||
stopGeneration()
|
||||
stopGeneration(activeTabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [activeModal, chatState, closeModal, setActiveSession, setActiveView, stopGeneration])
|
||||
}, [activeModal, activeTabId, chatState, closeModal, setActiveSession, setActiveView, stopGeneration])
|
||||
}
|
||||
|
||||
@ -434,6 +434,15 @@ export const en = {
|
||||
'serverVerb.Thinking': 'Thinking',
|
||||
'serverVerb.Task started': 'Task started',
|
||||
'serverVerb.Task in progress': 'Task in progress',
|
||||
|
||||
// ─── Tabs ──────────────────────────────────────
|
||||
'tabs.close': 'Close',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeRight': 'Close to the Right',
|
||||
'tabs.closeConfirmTitle': 'Session Running',
|
||||
'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
|
||||
'tabs.closeConfirmKeep': 'Keep Running',
|
||||
'tabs.closeConfirmStop': 'Stop & Close',
|
||||
} as const
|
||||
|
||||
export type TranslationKey = keyof typeof en
|
||||
|
||||
@ -436,4 +436,13 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'serverVerb.Thinking': '思考中',
|
||||
'serverVerb.Task started': '任务已启动',
|
||||
'serverVerb.Task in progress': '任务进行中',
|
||||
|
||||
// ─── Tabs ──────────────────────────────────────
|
||||
'tabs.close': '关闭',
|
||||
'tabs.closeOthers': '关闭其他',
|
||||
'tabs.closeRight': '关闭右侧',
|
||||
'tabs.closeConfirmTitle': '会话运行中',
|
||||
'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
|
||||
'tabs.closeConfirmKeep': '保持运行',
|
||||
'tabs.closeConfirmStop': '停止并关闭',
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { MessageList } from '../components/chat/MessageList'
|
||||
@ -7,18 +8,20 @@ import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
||||
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
|
||||
|
||||
export function ActiveSession() {
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const connectToSession = useChatStore((s) => s.connectToSession)
|
||||
const { chatState, tokenUsage } = useChatStore()
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
|
||||
|
||||
const session = sessions.find((s) => s.id === activeSessionId)
|
||||
const session = sessions.find((s) => s.id === activeTabId)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
connectToSession(activeSessionId)
|
||||
if (activeTabId) {
|
||||
connectToSession(activeTabId)
|
||||
}
|
||||
}, [activeSessionId, connectToSession])
|
||||
}, [activeTabId, connectToSession])
|
||||
|
||||
const isActive = chatState !== 'idle'
|
||||
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
|
||||
@ -32,6 +35,8 @@ export function ActiveSession() {
|
||||
return `${Math.floor(diff / 86400000)}d ago`
|
||||
}, [session?.modifiedAt])
|
||||
|
||||
if (!activeTabId) return null
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
|
||||
{/* Session info header */}
|
||||
|
||||
@ -3,6 +3,7 @@ import { useTranslation } from '../i18n'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
|
||||
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
||||
import { ModelSelector } from '../components/controls/ModelSelector'
|
||||
@ -122,6 +123,7 @@ export function EmptySession() {
|
||||
try {
|
||||
const sessionId = await createSession(workDir || undefined)
|
||||
setActiveView('code')
|
||||
useTabStore.getState().openTab(sessionId, 'New Session')
|
||||
connectToSession(sessionId)
|
||||
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
||||
type: attachment.type,
|
||||
@ -129,7 +131,7 @@ export function EmptySession() {
|
||||
data: attachment.data,
|
||||
mimeType: attachment.mimeType,
|
||||
}))
|
||||
sendMessage(text, attachmentPayload)
|
||||
sendMessage(sessionId, text, attachmentPayload)
|
||||
setInput('')
|
||||
setAttachments([])
|
||||
} catch (error) {
|
||||
|
||||
@ -32,8 +32,39 @@ vi.mock('./teamStore', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./tabStore', () => ({
|
||||
useTabStore: {
|
||||
getState: () => ({
|
||||
updateTabStatus: vi.fn(),
|
||||
updateTabTitle: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./sessionStore', () => ({
|
||||
useSessionStore: {
|
||||
getState: () => ({
|
||||
updateSessionTitle: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./cliTaskStore', () => ({
|
||||
useCLITaskStore: {
|
||||
getState: () => ({
|
||||
fetchSessionTasks: vi.fn(),
|
||||
tasks: [],
|
||||
clearTasks: vi.fn(),
|
||||
setTasksFromTodos: vi.fn(),
|
||||
markCompletedAndDismissed: vi.fn(),
|
||||
refreshTasks: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore'
|
||||
|
||||
const TEST_SESSION_ID = 'test-session-1'
|
||||
const initialState = useChatStore.getState()
|
||||
|
||||
describe('chatStore history mapping', () => {
|
||||
@ -41,19 +72,7 @@ describe('chatStore history mapping', () => {
|
||||
sendMock.mockReset()
|
||||
useChatStore.setState({
|
||||
...initialState,
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'disconnected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
connectedSessionId: null,
|
||||
slashCommands: [],
|
||||
sessions: {},
|
||||
})
|
||||
})
|
||||
|
||||
@ -95,7 +114,29 @@ describe('chatStore history mapping', () => {
|
||||
})
|
||||
|
||||
it('keeps parent tool linkage for live tool events', () => {
|
||||
useChatStore.getState().handleServerMessage({
|
||||
// Initialize the session first
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
@ -103,7 +144,7 @@ describe('chatStore history mapping', () => {
|
||||
parentToolUseId: 'agent-1',
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage({
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tool-1',
|
||||
content: 'ok',
|
||||
@ -111,7 +152,7 @@ describe('chatStore history mapping', () => {
|
||||
parentToolUseId: 'agent-1',
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().messages).toMatchObject([
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{
|
||||
type: 'tool_use',
|
||||
toolUseId: 'tool-1',
|
||||
@ -126,13 +167,32 @@ describe('chatStore history mapping', () => {
|
||||
})
|
||||
|
||||
it('sends permission mode updates to the active session only', () => {
|
||||
useChatStore.getState().setSessionPermissionMode('acceptEdits')
|
||||
useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits')
|
||||
expect(sendMock).not.toHaveBeenCalled()
|
||||
|
||||
useChatStore.setState({ connectedSessionId: 'session-1' })
|
||||
useChatStore.getState().setSessionPermissionMode('acceptEdits')
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'session-1': {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
useChatStore.getState().setSessionPermissionMode('session-1', 'acceptEdits')
|
||||
|
||||
expect(sendMock).toHaveBeenCalledWith({
|
||||
expect(sendMock).toHaveBeenCalledWith('session-1', {
|
||||
type: 'set_permission_mode',
|
||||
mode: 'acceptEdits',
|
||||
})
|
||||
|
||||
@ -4,6 +4,7 @@ import { sessionsApi } from '../api/sessions'
|
||||
import { useTeamStore } from './teamStore'
|
||||
import { useSessionStore } from './sessionStore'
|
||||
import { useCLITaskStore } from './cliTaskStore'
|
||||
import { useTabStore } from './tabStore'
|
||||
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
||||
import type { MessageEntry } from '../types/session'
|
||||
import type { PermissionMode } from '../types/settings'
|
||||
@ -11,7 +12,7 @@ import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage,
|
||||
|
||||
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
||||
|
||||
type ChatStore = {
|
||||
export type PerSessionState = {
|
||||
messages: UIMessage[]
|
||||
chatState: ChatState
|
||||
connectionState: ConnectionState
|
||||
@ -29,31 +30,11 @@ type ChatStore = {
|
||||
tokenUsage: TokenUsage
|
||||
elapsedSeconds: number
|
||||
statusVerb: string
|
||||
connectedSessionId: string | null
|
||||
slashCommands: Array<{ name: string; description: string }>
|
||||
|
||||
// Actions
|
||||
connectToSession: (sessionId: string) => void
|
||||
disconnectSession: () => void
|
||||
sendMessage: (content: string, attachments?: AttachmentRef[]) => void
|
||||
respondToPermission: (requestId: string, allowed: boolean, rule?: string) => void
|
||||
setSessionPermissionMode: (mode: PermissionMode) => void
|
||||
stopGeneration: () => void
|
||||
loadHistory: (sessionId: string) => Promise<void>
|
||||
clearMessages: () => void
|
||||
handleServerMessage: (msg: ServerMessage) => void
|
||||
elapsedTimer: ReturnType<typeof setInterval> | null
|
||||
}
|
||||
|
||||
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
|
||||
|
||||
/** Track tool_use IDs for task-related tools, so we can refresh on tool_result */
|
||||
const pendingTaskToolUseIds = new Set<string>()
|
||||
|
||||
let msgCounter = 0
|
||||
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
const DEFAULT_SESSION_STATE: PerSessionState = {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'disconnected',
|
||||
@ -66,97 +47,127 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
connectedSessionId: null,
|
||||
slashCommands: [],
|
||||
elapsedTimer: null,
|
||||
}
|
||||
|
||||
connectToSession: (sessionId: string) => {
|
||||
const current = get().connectedSessionId
|
||||
if (current === sessionId) return
|
||||
function createDefaultSessionState(): PerSessionState {
|
||||
return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } }
|
||||
}
|
||||
|
||||
// Disconnect previous
|
||||
if (current) wsManager.disconnect()
|
||||
type ChatStore = {
|
||||
sessions: Record<string, PerSessionState>
|
||||
|
||||
set({
|
||||
connectedSessionId: sessionId,
|
||||
connectionState: 'connecting',
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
elapsedSeconds: 0,
|
||||
slashCommands: [],
|
||||
})
|
||||
getSession: (sessionId: string) => PerSessionState
|
||||
connectToSession: (sessionId: string) => void
|
||||
disconnectSession: (sessionId: string) => void
|
||||
sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
|
||||
respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void
|
||||
setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
|
||||
stopGeneration: (sessionId: string) => void
|
||||
loadHistory: (sessionId: string) => Promise<void>
|
||||
clearMessages: (sessionId: string) => void
|
||||
handleServerMessage: (sessionId: string, msg: ServerMessage) => void
|
||||
}
|
||||
|
||||
// Clear all previous handlers before registering new ones
|
||||
// This prevents handler accumulation causing message duplication
|
||||
wsManager.clearHandlers()
|
||||
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
|
||||
const pendingTaskToolUseIds = new Set<string>()
|
||||
|
||||
let msgCounter = 0
|
||||
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
|
||||
|
||||
/** Helper: immutably update a specific session within the sessions record */
|
||||
function updateSessionIn(
|
||||
sessions: Record<string, PerSessionState>,
|
||||
sessionId: string,
|
||||
updater: (s: PerSessionState) => Partial<PerSessionState>,
|
||||
): Record<string, PerSessionState> {
|
||||
const session = sessions[sessionId]
|
||||
if (!session) return sessions
|
||||
return { ...sessions, [sessionId]: { ...session, ...updater(session) } }
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
sessions: {},
|
||||
|
||||
getSession: (sessionId) => get().sessions[sessionId] ?? createDefaultSessionState(),
|
||||
|
||||
connectToSession: (sessionId) => {
|
||||
const existing = get().sessions[sessionId]
|
||||
if (existing && existing.connectionState !== 'disconnected') return
|
||||
|
||||
set((s) => ({
|
||||
sessions: {
|
||||
...s.sessions,
|
||||
[sessionId]: {
|
||||
...createDefaultSessionState(),
|
||||
connectionState: 'connecting',
|
||||
messages: existing?.messages ?? [],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
wsManager.clearHandlers(sessionId)
|
||||
wsManager.connect(sessionId)
|
||||
wsManager.onMessage((msg) => {
|
||||
wsManager.onMessage(sessionId, (msg) => {
|
||||
if (msg.type === 'connected') {
|
||||
set({ connectionState: 'connected' })
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ connectionState: 'connected' })) }))
|
||||
}
|
||||
get().handleServerMessage(msg)
|
||||
get().handleServerMessage(sessionId, msg)
|
||||
})
|
||||
|
||||
// Load history and tasks
|
||||
get().loadHistory(sessionId)
|
||||
useCLITaskStore.getState().fetchSessionTasks(sessionId)
|
||||
sessionsApi.getSlashCommands(sessionId)
|
||||
.then(({ commands }) => {
|
||||
if (get().connectedSessionId === sessionId) {
|
||||
set({ slashCommands: commands })
|
||||
if (get().sessions[sessionId]) {
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: commands })) }))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (get().connectedSessionId === sessionId) {
|
||||
set({ slashCommands: [] })
|
||||
if (get().sessions[sessionId]) {
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ slashCommands: [] })) }))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
disconnectSession: () => {
|
||||
wsManager.disconnect()
|
||||
if (elapsedTimer) clearInterval(elapsedTimer)
|
||||
useCLITaskStore.getState().clearTasks()
|
||||
set({ connectedSessionId: null, chatState: 'idle' })
|
||||
disconnectSession: (sessionId) => {
|
||||
const session = get().sessions[sessionId]
|
||||
if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
wsManager.disconnect(sessionId)
|
||||
set((s) => {
|
||||
const { [sessionId]: _, ...rest } = s.sessions
|
||||
return { sessions: rest }
|
||||
})
|
||||
},
|
||||
|
||||
sendMessage: (content: string, attachments?: AttachmentRef[]) => {
|
||||
sendMessage: (sessionId, content, attachments?) => {
|
||||
const userFacingContent = content.trim()
|
||||
const uiAttachments: UIAttachment[] | undefined =
|
||||
attachments && attachments.length > 0
|
||||
? attachments.map((attachment) => ({
|
||||
type: attachment.type,
|
||||
name: attachment.name || attachment.path || attachment.mimeType || attachment.type,
|
||||
data: attachment.data,
|
||||
mimeType: attachment.mimeType,
|
||||
? attachments.map((a) => ({
|
||||
type: a.type,
|
||||
name: a.name || a.path || a.mimeType || a.type,
|
||||
data: a.data,
|
||||
mimeType: a.mimeType,
|
||||
}))
|
||||
: undefined
|
||||
|
||||
// If all tasks are completed, inline the task summary before the new user message
|
||||
const taskStore = useCLITaskStore.getState()
|
||||
const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed')
|
||||
|
||||
// Add user message to UI (with optional task summary before it)
|
||||
set((s) => {
|
||||
const newMessages = [...s.messages]
|
||||
const session = s.sessions[sessionId]
|
||||
if (!session) return s
|
||||
|
||||
const newMessages = [...session.messages]
|
||||
if (allTasksDone) {
|
||||
newMessages.push({
|
||||
id: nextId(),
|
||||
type: 'task_summary',
|
||||
tasks: taskStore.tasks.map((t) => ({
|
||||
id: t.id,
|
||||
subject: t.subject,
|
||||
status: t.status,
|
||||
activeForm: t.activeForm,
|
||||
})),
|
||||
tasks: taskStore.tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm })),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
// Clear sticky task bar since we inlined the summary
|
||||
taskStore.clearTasks()
|
||||
}
|
||||
newMessages.push({
|
||||
@ -166,64 +177,66 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
attachments: uiAttachments,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) }))
|
||||
}, 1000)
|
||||
|
||||
return {
|
||||
messages: newMessages,
|
||||
chatState: 'thinking',
|
||||
elapsedSeconds: 0,
|
||||
streamingText: '',
|
||||
statusVerb: randomSpinnerVerb(),
|
||||
sessions: {
|
||||
...s.sessions,
|
||||
[sessionId]: {
|
||||
...session,
|
||||
messages: newMessages,
|
||||
chatState: 'thinking',
|
||||
elapsedSeconds: 0,
|
||||
streamingText: '',
|
||||
statusVerb: randomSpinnerVerb(),
|
||||
elapsedTimer: timer,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Start elapsed timer
|
||||
if (elapsedTimer) clearInterval(elapsedTimer)
|
||||
elapsedTimer = setInterval(() => {
|
||||
set((s) => ({ elapsedSeconds: s.elapsedSeconds + 1 }))
|
||||
}, 1000)
|
||||
|
||||
wsManager.send({ type: 'user_message', content, attachments })
|
||||
wsManager.send(sessionId, { type: 'user_message', content, attachments })
|
||||
},
|
||||
|
||||
respondToPermission: (requestId: string, allowed: boolean, rule?: string) => {
|
||||
wsManager.send({ type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
|
||||
set({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })
|
||||
respondToPermission: (sessionId, requestId, allowed, rule?) => {
|
||||
wsManager.send(sessionId, { type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
|
||||
},
|
||||
|
||||
setSessionPermissionMode: (mode) => {
|
||||
if (!get().connectedSessionId) return
|
||||
wsManager.send({ type: 'set_permission_mode', mode })
|
||||
setSessionPermissionMode: (sessionId, mode) => {
|
||||
if (!get().sessions[sessionId]) return
|
||||
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
|
||||
},
|
||||
|
||||
stopGeneration: () => {
|
||||
wsManager.send({ type: 'stop_generation' })
|
||||
if (elapsedTimer) clearInterval(elapsedTimer)
|
||||
set({ chatState: 'idle' })
|
||||
stopGeneration: (sessionId) => {
|
||||
wsManager.send(sessionId, { type: 'stop_generation' })
|
||||
set((s) => {
|
||||
const session = s.sessions[sessionId]
|
||||
if (!session) return s
|
||||
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
return { sessions: { ...s.sessions, [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null } } }
|
||||
})
|
||||
},
|
||||
|
||||
loadHistory: async (sessionId: string) => {
|
||||
loadHistory: async (sessionId) => {
|
||||
try {
|
||||
const { messages } = await sessionsApi.getMessages(sessionId)
|
||||
const uiMessages = mapHistoryMessagesToUiMessages(messages)
|
||||
|
||||
set((state) => {
|
||||
if (state.connectedSessionId !== sessionId || state.messages.length > 0) {
|
||||
return state
|
||||
}
|
||||
return { ...state, messages: uiMessages }
|
||||
const session = state.sessions[sessionId]
|
||||
if (!session || session.messages.length > 0) return state
|
||||
return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages })) }
|
||||
})
|
||||
|
||||
// Extract the last TodoWrite input from history so TaskBar shows for V1 sessions
|
||||
const lastTodos = extractLastTodoWriteFromHistory(messages)
|
||||
if (lastTodos && lastTodos.length > 0) {
|
||||
const taskStore = useCLITaskStore.getState()
|
||||
// Only set if V2 task fetch didn't already populate tasks
|
||||
if (taskStore.tasks.length === 0) {
|
||||
taskStore.setTasksFromTodos(lastTodos)
|
||||
}
|
||||
if (taskStore.tasks.length === 0) taskStore.setTasksFromTodos(lastTodos)
|
||||
}
|
||||
|
||||
// For both V1 and V2: if all tasks completed and the user already
|
||||
// continued chatting, suppress the sticky TaskBar on page refresh.
|
||||
if (hasUserMessagesAfterTaskCompletion(messages)) {
|
||||
useCLITaskStore.getState().markCompletedAndDismissed()
|
||||
}
|
||||
@ -232,89 +245,79 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages: () => set({ messages: [], streamingText: '', chatState: 'idle' }),
|
||||
clearMessages: (sessionId) => {
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], streamingText: '', chatState: 'idle' })) }))
|
||||
},
|
||||
|
||||
handleServerMessage: (sessionId, msg) => {
|
||||
const update = (updater: (session: PerSessionState) => Partial<PerSessionState>) => {
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, updater) }))
|
||||
}
|
||||
|
||||
handleServerMessage: (msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
break
|
||||
|
||||
case 'status':
|
||||
set({
|
||||
update((session) => ({
|
||||
chatState: msg.state,
|
||||
// Only override statusVerb if the server sends something other than
|
||||
// the generic 'Thinking' — otherwise keep the random verb we picked
|
||||
// in sendMessage so the user sees fun loading text.
|
||||
...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}),
|
||||
...(msg.tokens ? { tokenUsage: { ...get().tokenUsage, output_tokens: msg.tokens } } : {}),
|
||||
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
|
||||
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
|
||||
})
|
||||
if (msg.state === 'idle' && elapsedTimer) {
|
||||
clearInterval(elapsedTimer)
|
||||
elapsedTimer = null
|
||||
}))
|
||||
if (msg.state === 'idle') {
|
||||
const session = get().sessions[sessionId]
|
||||
if (session?.elapsedTimer) {
|
||||
clearInterval(session.elapsedTimer)
|
||||
update(() => ({ elapsedTimer: null }))
|
||||
}
|
||||
}
|
||||
// Sync tab status
|
||||
useTabStore.getState().updateTabStatus(sessionId, msg.state === 'idle' ? 'idle' : 'running')
|
||||
break
|
||||
|
||||
case 'content_start': {
|
||||
// Flush any accumulated streamingText as assistant_text BEFORE
|
||||
// switching to the next block. This preserves intermediate text
|
||||
// segments between tool calls (e.g. "项目已经有 node_modules,直接启动").
|
||||
const pendingText = get().streamingText.trim()
|
||||
const session = get().sessions[sessionId]
|
||||
if (!session) break
|
||||
const pendingText = session.streamingText.trim()
|
||||
if (pendingText) {
|
||||
set((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: nextId(),
|
||||
type: 'assistant_text',
|
||||
content: pendingText,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
update((s) => ({
|
||||
messages: [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }],
|
||||
streamingText: '',
|
||||
}))
|
||||
}
|
||||
|
||||
if (msg.blockType === 'text') {
|
||||
set({ streamingText: '', chatState: 'streaming', activeThinkingId: null })
|
||||
update(() => ({ streamingText: '', chatState: 'streaming', activeThinkingId: null }))
|
||||
} else if (msg.blockType === 'tool_use') {
|
||||
set({
|
||||
update(() => ({
|
||||
activeToolUseId: msg.toolUseId ?? null,
|
||||
activeToolName: msg.toolName ?? null,
|
||||
streamingToolInput: '',
|
||||
chatState: 'tool_executing',
|
||||
activeThinkingId: null,
|
||||
})
|
||||
}))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'content_delta':
|
||||
if (msg.text !== undefined) {
|
||||
set((s) => ({ streamingText: s.streamingText + msg.text }))
|
||||
}
|
||||
if (msg.toolInput !== undefined) {
|
||||
set((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
|
||||
}
|
||||
if (msg.text !== undefined) update((s) => ({ streamingText: s.streamingText + msg.text }))
|
||||
if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
|
||||
break
|
||||
|
||||
case 'thinking':
|
||||
// Merge consecutive thinking deltas into one message.
|
||||
// Also flush any pending streamingText first — otherwise the text
|
||||
// becomes invisible because MessageList only renders streamingText
|
||||
// when chatState === 'streaming', and we're about to set it to 'thinking'.
|
||||
set((s) => {
|
||||
update((s) => {
|
||||
const pendingText = s.streamingText.trim()
|
||||
const base = pendingText
|
||||
? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }]
|
||||
: s.messages
|
||||
|
||||
const last = base[base.length - 1]
|
||||
if (last && last.type === 'thinking') {
|
||||
// Append to existing thinking message
|
||||
const updated = [...base]
|
||||
updated[updated.length - 1] = { ...last, content: last.content + msg.text }
|
||||
return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' }
|
||||
}
|
||||
const id = nextId()
|
||||
// Create new thinking message
|
||||
return {
|
||||
messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }],
|
||||
chatState: 'thinking',
|
||||
@ -325,49 +328,33 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
break
|
||||
|
||||
case 'tool_use_complete': {
|
||||
const toolName = msg.toolName || get().activeToolName || 'unknown'
|
||||
set((s) => ({
|
||||
const session = get().sessions[sessionId]
|
||||
const toolName = msg.toolName || session?.activeToolName || 'unknown'
|
||||
update((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: nextId(),
|
||||
type: 'tool_use',
|
||||
toolName,
|
||||
id: nextId(), type: 'tool_use', toolName,
|
||||
toolUseId: msg.toolUseId || s.activeToolUseId || '',
|
||||
input: msg.input,
|
||||
timestamp: Date.now(),
|
||||
parentToolUseId: msg.parentToolUseId,
|
||||
input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
|
||||
}],
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '',
|
||||
}))
|
||||
// TodoWrite: input contains the full todo list — update tasks immediately
|
||||
if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) {
|
||||
useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos)
|
||||
} else if (TASK_TOOL_NAMES.has(toolName)) {
|
||||
// V2 task tools — refresh will happen on tool_result
|
||||
// when the tool has actually finished executing and written to disk
|
||||
const useId = msg.toolUseId || get().activeToolUseId
|
||||
const useId = msg.toolUseId || session?.activeToolUseId
|
||||
if (useId) pendingTaskToolUseIds.add(useId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_result':
|
||||
set((s) => ({
|
||||
update((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: nextId(),
|
||||
type: 'tool_result',
|
||||
toolUseId: msg.toolUseId,
|
||||
content: msg.content,
|
||||
isError: msg.isError,
|
||||
timestamp: Date.now(),
|
||||
parentToolUseId: msg.parentToolUseId,
|
||||
id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId,
|
||||
content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
|
||||
}],
|
||||
chatState: 'thinking',
|
||||
activeThinkingId: null,
|
||||
chatState: 'thinking', activeThinkingId: null,
|
||||
}))
|
||||
// Refresh tasks after a task tool has finished executing
|
||||
if (pendingTaskToolUseIds.has(msg.toolUseId)) {
|
||||
pendingTaskToolUseIds.delete(msg.toolUseId)
|
||||
useCLITaskStore.getState().refreshTasks()
|
||||
@ -375,230 +362,118 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
break
|
||||
|
||||
case 'permission_request':
|
||||
set({
|
||||
pendingPermission: {
|
||||
requestId: msg.requestId,
|
||||
toolName: msg.toolName,
|
||||
input: msg.input,
|
||||
description: msg.description,
|
||||
},
|
||||
update((s) => ({
|
||||
pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description },
|
||||
chatState: 'permission_pending',
|
||||
activeThinkingId: null,
|
||||
})
|
||||
set((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: nextId(),
|
||||
type: 'permission_request',
|
||||
requestId: msg.requestId,
|
||||
toolName: msg.toolName,
|
||||
input: msg.input,
|
||||
description: msg.description,
|
||||
timestamp: Date.now(),
|
||||
id: nextId(), type: 'permission_request', requestId: msg.requestId,
|
||||
toolName: msg.toolName, input: msg.input, description: msg.description, timestamp: Date.now(),
|
||||
}],
|
||||
}))
|
||||
break
|
||||
|
||||
case 'message_complete': {
|
||||
// Flush streaming text as a message
|
||||
const text = get().streamingText
|
||||
const session = get().sessions[sessionId]
|
||||
if (!session) break
|
||||
const text = session.streamingText
|
||||
if (text) {
|
||||
set((s) => ({
|
||||
update((s) => ({
|
||||
messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }],
|
||||
streamingText: '',
|
||||
}))
|
||||
}
|
||||
set({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null })
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer)
|
||||
elapsedTimer = null
|
||||
}
|
||||
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null }))
|
||||
break
|
||||
}
|
||||
|
||||
case 'error':
|
||||
set((s) => ({
|
||||
update((s) => ({
|
||||
messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }],
|
||||
chatState: 'idle',
|
||||
activeThinkingId: null,
|
||||
chatState: 'idle', activeThinkingId: null,
|
||||
}))
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer)
|
||||
elapsedTimer = null
|
||||
useTabStore.getState().updateTabStatus(sessionId, 'error')
|
||||
{
|
||||
const session = get().sessions[sessionId]
|
||||
if (session?.elapsedTimer) {
|
||||
clearInterval(session.elapsedTimer)
|
||||
update(() => ({ elapsedTimer: null }))
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'team_created':
|
||||
useTeamStore.getState().handleTeamCreated(msg.teamName)
|
||||
break
|
||||
|
||||
case 'team_update':
|
||||
useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members)
|
||||
break
|
||||
|
||||
case 'team_deleted':
|
||||
useTeamStore.getState().handleTeamDeleted(msg.teamName)
|
||||
break
|
||||
|
||||
case 'task_update':
|
||||
break
|
||||
|
||||
case 'session_title_updated':
|
||||
useSessionStore.getState().updateSessionTitle(msg.sessionId, msg.title)
|
||||
useTabStore.getState().updateTabTitle(msg.sessionId, msg.title)
|
||||
break
|
||||
|
||||
case 'system_notification':
|
||||
// Cache slash commands from CLI init
|
||||
if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) {
|
||||
set({ slashCommands: msg.data as Array<{ name: string; description: string }> })
|
||||
update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> }))
|
||||
}
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
break
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
type AssistantHistoryBlock = {
|
||||
type: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
id?: string
|
||||
input?: unknown
|
||||
}
|
||||
// ─── History mapping helpers (unchanged from original) ─────────
|
||||
|
||||
type UserHistoryBlock = {
|
||||
type: string
|
||||
text?: string
|
||||
tool_use_id?: string
|
||||
content?: unknown
|
||||
is_error?: boolean
|
||||
source?: { data?: string }
|
||||
mimeType?: string
|
||||
media_type?: string
|
||||
name?: string
|
||||
}
|
||||
type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown }
|
||||
type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string }
|
||||
|
||||
export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] {
|
||||
const uiMessages: UIMessage[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
const timestamp = new Date(msg.timestamp).getTime()
|
||||
|
||||
if (msg.type === 'user' && typeof msg.content === 'string') {
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'user_text',
|
||||
content: msg.content,
|
||||
timestamp,
|
||||
})
|
||||
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant' && typeof msg.content === 'string') {
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'assistant_text',
|
||||
content: msg.content,
|
||||
timestamp,
|
||||
model: msg.model,
|
||||
})
|
||||
uiMessages.push({ id: msg.id || nextId(), type: 'assistant_text', content: msg.content, timestamp, model: msg.model })
|
||||
continue
|
||||
}
|
||||
|
||||
// Server marks assistant messages containing tool_use blocks as type 'tool_use',
|
||||
// but the content array structure is the same as 'assistant' — handle both.
|
||||
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content as AssistantHistoryBlock[]) {
|
||||
if (block.type === 'thinking' && block.thinking) {
|
||||
uiMessages.push({
|
||||
id: nextId(),
|
||||
type: 'thinking',
|
||||
content: block.thinking,
|
||||
timestamp,
|
||||
})
|
||||
} else if (block.type === 'text' && block.text) {
|
||||
uiMessages.push({
|
||||
id: nextId(),
|
||||
type: 'assistant_text',
|
||||
content: block.text,
|
||||
timestamp,
|
||||
model: msg.model,
|
||||
})
|
||||
} else if (block.type === 'tool_use') {
|
||||
uiMessages.push({
|
||||
id: nextId(),
|
||||
type: 'tool_use',
|
||||
toolName: block.name ?? 'unknown',
|
||||
toolUseId: block.id ?? '',
|
||||
input: block.input,
|
||||
timestamp,
|
||||
parentToolUseId: msg.parentToolUseId,
|
||||
})
|
||||
}
|
||||
if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
|
||||
else if (block.type === 'text' && block.text) uiMessages.push({ id: nextId(), type: 'assistant_text', content: block.text, timestamp, model: msg.model })
|
||||
else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Server marks user messages containing tool_result blocks as type 'tool_result',
|
||||
// but the content array structure is the same as 'user' — handle both.
|
||||
if ((msg.type === 'user' || msg.type === 'tool_result') && Array.isArray(msg.content)) {
|
||||
const textParts: string[] = []
|
||||
const attachments: UIAttachment[] = []
|
||||
|
||||
for (const block of msg.content as UserHistoryBlock[]) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
textParts.push(block.text)
|
||||
} else if (block.type === 'image') {
|
||||
attachments.push({
|
||||
type: 'image',
|
||||
name: block.name || 'image',
|
||||
data: block.source?.data,
|
||||
mimeType: block.mimeType || block.media_type,
|
||||
})
|
||||
} else if (block.type === 'file') {
|
||||
attachments.push({
|
||||
type: 'file',
|
||||
name: block.name || 'file',
|
||||
})
|
||||
} else if (block.type === 'tool_result') {
|
||||
uiMessages.push({
|
||||
id: nextId(),
|
||||
type: 'tool_result',
|
||||
toolUseId: block.tool_use_id ?? '',
|
||||
content: block.content,
|
||||
isError: !!block.is_error,
|
||||
timestamp,
|
||||
parentToolUseId: msg.parentToolUseId,
|
||||
})
|
||||
}
|
||||
if (block.type === 'text' && block.text) textParts.push(block.text)
|
||||
else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type })
|
||||
else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' })
|
||||
else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
|
||||
}
|
||||
|
||||
if (textParts.length > 0 || attachments.length > 0) {
|
||||
uiMessages.push({
|
||||
id: nextId(),
|
||||
type: 'user_text',
|
||||
content: textParts.join('\n'),
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
timestamp,
|
||||
})
|
||||
uiMessages.push({ id: nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uiMessages
|
||||
}
|
||||
|
||||
/** Scan history messages for the last TodoWrite tool_use and return the todos array.
|
||||
* Returns null if all todos were completed and the user continued chatting after. */
|
||||
function extractLastTodoWriteFromHistory(
|
||||
messages: MessageEntry[],
|
||||
): Array<{ content: string; status: string; activeForm?: string }> | null {
|
||||
function extractLastTodoWriteFromHistory(messages: MessageEntry[]): Array<{ content: string; status: string; activeForm?: string }> | null {
|
||||
let foundIndex = -1
|
||||
let todos: Array<{ content: string; status: string; activeForm?: string }> | null = null
|
||||
|
||||
// Walk backwards to find the most recent TodoWrite
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!
|
||||
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
|
||||
@ -617,45 +492,28 @@ function extractLastTodoWriteFromHistory(
|
||||
if (todos) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!todos) return null
|
||||
|
||||
// If all todos are completed and there are user messages after, the tasks
|
||||
// were already inlined — don't re-show the sticky bar.
|
||||
const allDone = todos.every((t) => t.status === 'completed')
|
||||
if (allDone) {
|
||||
for (let i = foundIndex + 1; i < messages.length; i++) {
|
||||
const msg = messages[i]!
|
||||
if (msg.type === 'user' && msg.content) return null
|
||||
if (messages[i]!.type === 'user' && messages[i]!.content) return null
|
||||
}
|
||||
}
|
||||
|
||||
return todos
|
||||
}
|
||||
|
||||
const TASK_RELATED_TOOL_NAMES = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
|
||||
|
||||
/** Check if there are user messages after the last task-related tool call.
|
||||
* Used to determine if completed tasks should be shown as sticky or inline. */
|
||||
function hasUserMessagesAfterTaskCompletion(messages: MessageEntry[]): boolean {
|
||||
// Find the index of the last task-related tool call
|
||||
let lastTaskIndex = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!
|
||||
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
|
||||
const blocks = msg.content as AssistantHistoryBlock[]
|
||||
if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) {
|
||||
lastTaskIndex = i
|
||||
break
|
||||
}
|
||||
if (blocks.some((b) => b.type === 'tool_use' && TASK_RELATED_TOOL_NAMES.has(b.name ?? ''))) { lastTaskIndex = i; break }
|
||||
}
|
||||
}
|
||||
|
||||
if (lastTaskIndex < 0) return false
|
||||
|
||||
// Check for user messages after the last task tool
|
||||
for (let i = lastTaskIndex + 1; i < messages.length; i++) {
|
||||
if (messages[i]!.type === 'user') return true
|
||||
}
|
||||
for (let i = lastTaskIndex + 1; i < messages.length; i++) { if (messages[i]!.type === 'user') return true }
|
||||
return false
|
||||
}
|
||||
|
||||
128
desktop/src/stores/tabStore.ts
Normal file
128
desktop/src/stores/tabStore.ts
Normal 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 */ }
|
||||
},
|
||||
}))
|
||||
1814
docs/superpowers/plans/2026-04-09-multi-tab-sessions.md
Normal file
1814
docs/superpowers/plans/2026-04-09-multi-tab-sessions.md
Normal file
File diff suppressed because it is too large
Load Diff
221
docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md
Normal file
221
docs/superpowers/specs/2026-04-08-multi-tab-sessions-design.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Multi-Tab Sessions Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Desktop webapp (Tauri + React + Zustand) 新增多 Tab 功能,允许用户同时打开多个 Session 并行工作。类似 VS Code 的编辑器 Tab 与文件浏览器的关系:Sidebar 浏览所有 Session,Tab 栏管理当前打开的 Session。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Desktop App**: Tauri v2 + React 18 + Zustand 5 + Tailwind CSS 4 + Vite
|
||||
- **Backend Server**: `src/server/` — 已支持多 Session 并发 (`maxSessions` 配置)
|
||||
- **通信协议**: 每个 Session 独立 WebSocket (`/ws/${sessionId}`) + REST API
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Tab 与 Sidebar 关系 | 独立共存 (VS Code 模式) | Sidebar 浏览全部历史, Tab 管理当前打开的 |
|
||||
| Tab 栏位置 | 内容区域顶部 (Sidebar 右侧上方) | 不影响 Sidebar 布局 |
|
||||
| 并发策略 | 每个 Tab 独立 WebSocket 连接 | 真正并行, 后端已支持, WS 连接轻量 |
|
||||
| Tab 数量限制 | 不限制 | 用户自由管理 |
|
||||
| Tab 溢出处理 | 固定宽度 + 左右滚动箭头 | VS Code 风格 |
|
||||
| 关闭运行中 Tab | 可配置 (默认弹确认框) | 配置项: ask / background / stop |
|
||||
| Tab 持久化 | 可配置 (默认恢复) | 存 localStorage, 启动时恢复 |
|
||||
| Sidebar 打开行为 | 去重 + 聚焦 | 已打开则跳转, 否则新 Tab |
|
||||
| 新建 Session 入口 | 仅 Sidebar | 保持单一入口 |
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
### 1. New Tab Store (`tabStore.ts`)
|
||||
|
||||
新增 Zustand store 管理 Tab 状态:
|
||||
|
||||
```ts
|
||||
interface Tab {
|
||||
sessionId: string
|
||||
title: string
|
||||
status: 'idle' | 'running' | 'error'
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null // sessionId
|
||||
|
||||
openTab(sessionId: string, title: string): void
|
||||
closeTab(sessionId: string): void
|
||||
setActiveTab(sessionId: string): void
|
||||
updateTabTitle(sessionId: string, title: string): void
|
||||
updateTabStatus(sessionId: string, status: Tab['status']): void
|
||||
reorderTabs(fromIndex: number, toIndex: number): void
|
||||
|
||||
// Persistence
|
||||
saveTabs(): void
|
||||
restoreTabs(): Tab[]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. chatStore Refactor
|
||||
|
||||
从单 Session 改为多 Session 并发:
|
||||
|
||||
**Before**: 单一 `messages[]` + 单一 `connectedSessionId`
|
||||
**After**: 按 sessionId 隔离的 session map
|
||||
|
||||
```ts
|
||||
interface PerSessionState {
|
||||
messages: UIMessage[]
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected'
|
||||
streamingState: StreamingState
|
||||
permissionRequests: PermissionRequest[]
|
||||
tasks: TaskState[]
|
||||
}
|
||||
|
||||
interface ChatStore {
|
||||
sessions: Record<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 滚动
|
||||
Loading…
x
Reference in New Issue
Block a user