fix: resolve TypeScript compilation errors in multi-tab refactor

Update all components, hooks, and tests to use the new per-session
chatStore API where state is keyed by sessionId under `sessions` and
all action methods require sessionId as the first parameter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 00:42:18 +08:00
parent e462314100
commit 9bfbadd170
12 changed files with 1553 additions and 156 deletions

1266
desktop/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -14,6 +14,7 @@ import { ToolInspection } from '../pages/ToolInspection'
import { AppShell } from '../components/layout/AppShell'
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
@ -44,12 +45,32 @@ describe('Content-only pages render without errors', () => {
})
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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -4,20 +4,12 @@ import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock'
import { PermissionDialog } from './PermissionDialog'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
describe('chat blocks', () => {
beforeEach(() => {
useChatStore.setState({
chatState: 'idle',
messages: [],
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
elapsedSeconds: 0,
})
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', status: 'idle' }] })
useChatStore.setState({ sessions: {} })
})
it('keeps thinking collapsed by default', () => {
@ -72,13 +64,30 @@ describe('chat blocks', () => {
it('shows a diff preview for edit permission requests', () => {
useChatStore.setState({
pendingPermission: {
requestId: 'perm-1',
toolName: 'Edit',
input: {
file_path: '/tmp/example.ts',
old_string: 'const count = 1',
new_string: 'const count = 2',
sessions: {
'active-tab': {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: {
requestId: 'perm-1',
toolName: 'Edit',
input: {
file_path: '/tmp/example.ts',
old_string: 'const count = 1',
new_string: 'const count = 2',
},
},
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
elapsedTimer: null,
},
},
})

View File

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

View File

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

View File

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

View File

@ -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',
})