From ceccf0845836fcdeca2afe5bb4f5256b70e365fe Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 10 May 2026 21:42:39 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E4=BC=9A=E8=AF=9D=E5=B1=95=E5=BC=80=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/layout/Sidebar.test.tsx | 48 ++++++++++++++++++++++ web/src/components/layout/Sidebar.tsx | 5 ++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index f7ab2bb8..786b8472 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -198,4 +198,52 @@ describe('Sidebar ProviderModal', () => { expect(showToast).not.toHaveBeenCalled() expect(screen.getByText('switch failed')).toBeInTheDocument() }) + + it('only shows the expanded workspace style on the current workspace', async () => { + const switchWorkspace = vi.fn().mockResolvedValue(undefined) + useWorkspaceStore.setState({ + workspaces: [ + { hash: 'w1', path: '/workspace-one', name: 'Workspace One', createdAt: '1', updatedAt: '1' }, + { hash: 'w2', path: '/workspace-two', name: 'Workspace Two', createdAt: '1', updatedAt: '1' }, + ], + currentWorkspaceHash: 'w1', + switchWorkspace, + } as any) + + render() + + const workspaceOne = screen.getByRole('button', { name: /Workspace One/i }) + const workspaceTwo = screen.getByRole('button', { name: /Workspace Two/i }) + const chevronFor = (button: HTMLElement) => { + const chevron = button.querySelector('.chevron') + if (!(chevron instanceof HTMLElement)) { + throw new Error('workspace chevron not found') + } + return chevron + } + + await waitFor(() => { + expect(chevronFor(workspaceOne)).toHaveClass('expanded') + }) + + fireEvent.click(workspaceOne) + await waitFor(() => { + expect(chevronFor(workspaceOne)).not.toHaveClass('expanded') + }) + fireEvent.click(workspaceOne) + await waitFor(() => { + expect(chevronFor(workspaceOne)).toHaveClass('expanded') + }) + + fireEvent.click(workspaceTwo) + await waitFor(() => { + expect(switchWorkspace).toHaveBeenCalledWith('w2', mockGatewayAPI) + }) + useWorkspaceStore.setState({ currentWorkspaceHash: 'w2' } as any) + + await waitFor(() => { + expect(chevronFor(workspaceOne)).not.toHaveClass('expanded') + expect(chevronFor(workspaceTwo)).toHaveClass('expanded') + }) + }) }) diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 385d689b..be781246 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -243,13 +243,14 @@ export default function Sidebar({ collapsed }: SidebarProps) { {filteredWorkspaces.map((ws) => { const expanded = expandedWorkspaces.has(ws.hash) const isCurrent = ws.hash === currentWorkspaceHash + const rowExpanded = isCurrent && expanded const sessionsForThisWorkspace = isCurrent ? filteredCurrentSessions : [] const isRenaming = renamingWorkspaceHash === ws.hash return (
handleDeleteWorkspace(ws.hash)} /> - {expanded && isCurrent && ( + {rowExpanded && (
{sessionsForThisWorkspace.length === 0 && (
暂无会话
From bc93f34da02c9a9737818b2a32d28351f3d3e013 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 10 May 2026 21:58:38 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:token=E7=BB=84=E4=BB=B6=E7=8E=AF?= =?UTF-8?q?=E6=94=B9=E6=88=90=E7=BB=BF=E9=BB=84=E7=BA=A2=E4=B8=89=E6=80=81?= =?UTF-8?q?=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/chat/ChatInput.test.tsx | 83 ++++++++++++++++++++++ web/src/components/chat/ChatInput.tsx | 78 ++++++++++++++++++-- 2 files changed, 156 insertions(+), 5 deletions(-) diff --git a/web/src/components/chat/ChatInput.test.tsx b/web/src/components/chat/ChatInput.test.tsx index bf39b1b2..d4d797eb 100644 --- a/web/src/components/chat/ChatInput.test.tsx +++ b/web/src/components/chat/ChatInput.test.tsx @@ -4,6 +4,7 @@ import ChatInput from './ChatInput' import { useChatStore } from '@/stores/useChatStore' import { useComposerStore } from '@/stores/useComposerStore' import { useSessionStore } from '@/stores/useSessionStore' +import { useRuntimeInsightStore } from '@/stores/useRuntimeInsightStore' const mockGatewayAPI = { listAvailableSkills: vi.fn(), @@ -31,6 +32,21 @@ async function submitSlashCommand(command: string) { fireEvent.keyDown(textarea, { key: 'Enter' }) } +function renderWithBudget(input: { + action: string + estimated_input_tokens: number + prompt_budget: number + context_window?: number +}) { + useRuntimeInsightStore.getState().setBudgetChecked({ + attempt_seq: 1, + request_hash: 'budget-test', + ...input, + }) + render() + return screen.getByTestId('budget-token-ring') +} + describe('ChatInput', () => { beforeEach(() => { vi.clearAllMocks() @@ -54,12 +70,14 @@ describe('ChatInput', () => { useComposerStore.setState({ composerText: '' }) useSessionStore.setState({ currentSessionId: '' } as never) + useRuntimeInsightStore.getState().reset() useChatStore.setState({ isGenerating: false, messages: [], permissionRequests: [], agentMode: 'build', permissionMode: 'default', + tokenUsage: null, } as never) }) @@ -203,4 +221,69 @@ describe('ChatInput', () => { expect(mockGatewayAPI.executeSystemTool).not.toHaveBeenCalled() }) }) + + it('shows a green budget ring below the warning threshold', () => { + const ring = renderWithBudget({ + action: 'allow', + estimated_input_tokens: 80, + prompt_budget: 100, + context_window: 200, + }) + + expect(ring).toHaveAttribute('stroke', 'var(--success)') + }) + + it('shows a yellow budget ring near the automatic compact threshold', () => { + const ring = renderWithBudget({ + action: 'allow', + estimated_input_tokens: 90, + prompt_budget: 100, + context_window: 200, + }) + + expect(ring).toHaveAttribute('stroke', 'var(--warning)') + }) + + it('shows a red budget ring near the context window limit', () => { + const ring = renderWithBudget({ + action: 'allow', + estimated_input_tokens: 190, + prompt_budget: 100, + context_window: 200, + }) + + expect(ring).toHaveAttribute('stroke', 'var(--error)') + }) + + it('falls back to prompt budget as the limit when context window is missing', () => { + const ring = renderWithBudget({ + action: 'allow', + estimated_input_tokens: 100, + prompt_budget: 100, + }) + + expect(ring).toHaveAttribute('stroke', 'var(--error)') + }) + + it('honors compact budget action as a yellow color override', () => { + const ring = renderWithBudget({ + action: 'compact', + estimated_input_tokens: 20, + prompt_budget: 100, + context_window: 200, + }) + + expect(ring).toHaveAttribute('stroke', 'var(--warning)') + }) + + it('honors stop budget action as a red color override', () => { + const ring = renderWithBudget({ + action: 'stop', + estimated_input_tokens: 20, + prompt_budget: 100, + context_window: 200, + }) + + expect(ring).toHaveAttribute('stroke', 'var(--error)') + }) }) diff --git a/web/src/components/chat/ChatInput.tsx b/web/src/components/chat/ChatInput.tsx index d5a4bb2e..5a4a172e 100644 --- a/web/src/components/chat/ChatInput.tsx +++ b/web/src/components/chat/ChatInput.tsx @@ -28,6 +28,9 @@ const slashMenuAnchorStyle: React.CSSProperties = { zIndex: 100, } +const budgetWarningThresholdRatio = 0.9 +const budgetDangerThresholdRatio = 0.95 + /** 将网关返回的技能列表转换成输入框使用的 slash 命令结构。 */ function buildSkillSlashCommands( skills: Array<{ descriptor: { id: string; description?: string }; active?: boolean }>, @@ -64,6 +67,59 @@ function extractSystemToolContent(result: unknown, fallback: string): string { return content || fallback } +/** 将预算事件转换为输入框圆环的语义状态,保持阈值和颜色判断集中。 */ +function resolveBudgetRingState( + budgetChecked: ReturnType['budgetChecked'], + budgetEstimateFailed: ReturnType['budgetEstimateFailed'], +) { + if (budgetEstimateFailed) { + return { + color: 'var(--error)', + label: '预算估算失败', + ratio: 0, + } + } + if (!budgetChecked) { + return { + color: 'var(--text-tertiary)', + label: '暂无预算数据', + ratio: 0, + } + } + + const estimatedTokens = Math.max(0, budgetChecked.estimated_input_tokens) + const promptBudget = Math.max(0, budgetChecked.prompt_budget) + const contextLimit = Math.max(0, budgetChecked.context_window || promptBudget) + const ringRatio = contextLimit > 0 ? Math.min(estimatedTokens / contextLimit, 1) : 0 + + if ( + budgetChecked.action === 'stop' || + (contextLimit > 0 && estimatedTokens >= contextLimit * budgetDangerThresholdRatio) || + (!budgetChecked.context_window && promptBudget > 0 && estimatedTokens >= promptBudget) + ) { + return { + color: 'var(--error)', + label: '接近上下文上限', + ratio: ringRatio, + } + } + if ( + budgetChecked.action === 'compact' || + (promptBudget > 0 && estimatedTokens >= promptBudget * budgetWarningThresholdRatio) + ) { + return { + color: 'var(--warning)', + label: '接近自动压缩阈值', + ratio: ringRatio, + } + } + return { + color: 'var(--success)', + label: '正常', + ratio: ringRatio, + } +} + export default function ChatInput() { const gatewayAPI = useGatewayAPI() const text = useComposerStore((state) => state.composerText) @@ -471,16 +527,17 @@ function BudgetTokenStrip() { const [popoverStyle, setPopoverStyle] = useState({}) const totalTokens = tokenUsage ? tokenUsage.input_tokens + tokenUsage.output_tokens : 0 - const ratio = budgetUsageRatio ?? 0 + const budgetRingState = resolveBudgetRingState(budgetChecked, budgetEstimateFailed) + const ratio = budgetRingState.ratio + const budgetPct = budgetUsageRatio ?? 0 const pct = Math.min(Math.round(ratio * 100), 100) + const budgetThresholdPct = Math.min(Math.round(budgetPct * 100), 100) // SVG ring: radius 8, circumference ~50 const r = 7 const circ = 2 * Math.PI * r const dash = (ratio * circ).toFixed(1) - let ringColor = 'var(--text-tertiary)' - if (budgetEstimateFailed || ratio > 0.8) ringColor = 'var(--error)' - else if (ratio > 0.6) ringColor = 'var(--warning)' + const ringColor = budgetRingState.color // Click outside to close useEffect(() => { @@ -542,6 +599,7 @@ function BudgetTokenStrip() { {budgetChecked && ( {budgetEstimateFailed.message}
) : budgetChecked ? ( <> +
+ 状态 + {budgetRingState.label} +
Budget {formatTokenCount(budgetChecked.prompt_budget)} @@ -582,9 +644,15 @@ function BudgetTokenStrip() {
已用 - {formatTokenCount(budgetChecked.estimated_input_tokens)} ({pct}%) + {formatTokenCount(budgetChecked.estimated_input_tokens)} ({budgetThresholdPct}%)
+ {budgetChecked.context_window && ( +
+ 上限占用 + {pct}% +
+ )} {totalTokens > 0 && (
本轮 Tokens From 92ea951b9c65ad02a3cc5a8f80a77092bc661bc6 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 10 May 2026 22:14:00 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=E6=97=A7=E7=9A=84verify=E4=B8=8D?= =?UTF-8?q?=E4=BC=9A=E5=86=8D=E5=8A=A0=E8=BD=BD=E6=B1=A1=E6=9F=93=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8C=BA=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/stores/useSessionStore.test.ts | 63 +++++++++++++++++++++++++- web/src/stores/useSessionStore.ts | 15 ++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/web/src/stores/useSessionStore.test.ts b/web/src/stores/useSessionStore.test.ts index 3f3703f0..e2d2bd1f 100644 --- a/web/src/stores/useSessionStore.test.ts +++ b/web/src/stores/useSessionStore.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { useSessionStore } from './useSessionStore' +import { mapHistoryMessages, useSessionStore } from './useSessionStore' import { useChatStore } from './useChatStore' import { useGatewayStore } from './useGatewayStore' import { useRuntimeInsightStore } from './useRuntimeInsightStore' @@ -12,6 +12,67 @@ beforeEach(() => { }) describe('useSessionStore', () => { + it('mapHistoryMessages skips internal system acceptance reminders', () => { + const mapped = mapHistoryMessages([ + { role: 'user', content: 'start' }, + { + role: 'system', + content: [ + '', + 'pending_todo', + '', + ].join(''), + }, + { role: 'assistant', content: 'visible answer' }, + ]) + + expect(mapped.map((m) => m.content)).toEqual(['start', 'visible answer']) + expect(mapped.every((m) => m.content.includes('acceptance_continue') === false)).toBe(true) + }) + + it('mapHistoryMessages skips leaked assistant acceptance control text', () => { + const mapped = mapHistoryMessages([ + { + role: 'assistant', + content: '', + }, + { role: 'assistant', content: 'normal assistant text' }, + { + role: 'assistant', + content: 'prefix pending_todo', + }, + ]) + + expect(mapped).toHaveLength(1) + expect(mapped[0].content).toBe('normal assistant text') + }) + + it('mapHistoryMessages keeps normal messages and merges tool results', () => { + const mapped = mapHistoryMessages([ + { role: 'user', content: 'please inspect' }, + { + role: 'assistant', + content: 'calling tool', + tool_calls: [ + { id: 'call-1', name: 'filesystem_read_file', arguments: '{"path":"README.md"}' }, + ], + }, + { role: 'tool', content: 'file content', tool_call_id: 'call-1' }, + ]) + + expect(mapped).toHaveLength(3) + expect(mapped[0]).toMatchObject({ role: 'user', type: 'text', content: 'please inspect' }) + expect(mapped[1]).toMatchObject({ role: 'assistant', type: 'text', content: 'calling tool' }) + expect(mapped[2]).toMatchObject({ + role: 'tool', + type: 'tool_call', + toolName: 'filesystem_read_file', + toolCallId: 'call-1', + toolResult: 'file content', + toolStatus: 'done', + }) + }) + it('createSession clears messages and resets session state', () => { useChatStore.getState().addMessage({ id: '1', role: 'user', content: 'hello', type: 'text', timestamp: 1 }) useSessionStore.setState({ currentSessionId: 'sess-1' }) diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts index 10755991..d7704304 100644 --- a/web/src/stores/useSessionStore.ts +++ b/web/src/stores/useSessionStore.ts @@ -157,12 +157,25 @@ export async function loadSessionWithInsights(gatewayAPI: GatewayAPI, sessionId: return sessionFrame } +/** isInternalHistoryMessage 识别仅供 runtime/provider 续跑使用、不能回放到 Web 聊天流的内部控制消息。 */ +function isInternalHistoryMessage(msg: BackendMessage): boolean { + const role = msg.role.trim().toLowerCase() + if (role === 'system') return true + + const content = msg.content.trim() + if (!content) return false + return content.startsWith('') || + content.includes('') || + content.includes('') +} + /** 将后端历史消息映射为前端 ChatMessage 列表,正确合并 tool_result 回 tool_call */ export function mapHistoryMessages(backendMessages: BackendMessage[]): Array['messages'][0]> { let _idCounter = 0 // Phase 1: Collect tool results by tool_call_id const toolResults = new Map() for (const msg of backendMessages) { + if (isInternalHistoryMessage(msg)) continue if (msg.tool_call_id) { toolResults.set(msg.tool_call_id, { content: msg.content, isError: !!msg.is_error }) } @@ -171,6 +184,8 @@ export function mapHistoryMessages(backendMessages: BackendMessage[]): Array['messages'][0]> = [] for (const msg of backendMessages) { + if (isInternalHistoryMessage(msg)) continue + // Skip bare tool result messages — they are merged into tool_call messages if (msg.tool_call_id) continue From c78adaafe7b0351a6f3f0b90360e975abdb1d4eb Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 10 May 2026 22:31:48 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=B0=E5=BA=95=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/chat/MessageItem.tsx | 2 +- web/src/components/chat/MessageList.test.tsx | 61 +++++++++++++++++++- web/src/components/chat/MessageList.tsx | 11 +++- web/src/stores/useChatStore.test.ts | 39 +++++++++++++ web/src/stores/useChatStore.ts | 2 + web/src/stores/useSessionStore.test.ts | 19 +++++- web/src/stores/useSessionStore.ts | 12 +--- 7 files changed, 132 insertions(+), 14 deletions(-) diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index 3118a388..58f89cff 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -90,7 +90,7 @@ function UserMessage({ message }: { message: ChatMessage }) { if (sessionData?.messages) { useChatStore.getState().clearMessages() const mapped = mapHistoryMessages(sessionData.messages) - for (const msg of mapped) useChatStore.getState().addMessage(msg) + useChatStore.getState().setMessages(mapped) } } catch (err) { const msg = err instanceof Error ? err.message : 'Revert failed' diff --git a/web/src/components/chat/MessageList.test.tsx b/web/src/components/chat/MessageList.test.tsx index a8af748b..c2a90b5f 100644 --- a/web/src/components/chat/MessageList.test.tsx +++ b/web/src/components/chat/MessageList.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { act, render, screen } from '@testing-library/react' import MessageList from './MessageList' import { useChatStore } from '@/stores/useChatStore' @@ -10,8 +10,12 @@ vi.mock('./MessageItem', () => ({ })) describe('MessageList', () => { + let scrollIntoViewMock: ReturnType + beforeEach(() => { useChatStore.setState({ messages: [], isGenerating: false } as any) + scrollIntoViewMock = vi.fn() + window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock as unknown as typeof window.HTMLElement.prototype.scrollIntoView }) it('renders empty state when no messages', () => { @@ -33,4 +37,59 @@ describe('MessageList', () => { const ids = screen.getAllByTestId(/msg-/).map((x) => x.textContent) expect(ids).toEqual(['u1:solo', 't1:solo', 'a2:group', 'a1:group']) }) + + it('scrolls instantly to the bottom when history messages first load', () => { + useChatStore.setState({ + messages: [ + { id: 'u1', role: 'user', type: 'text', content: 'q', timestamp: 1 }, + { id: 'a1', role: 'assistant', type: 'text', content: 'answer', timestamp: 2 }, + ], + isGenerating: false, + } as any) + + render() + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'instant' }) + }) + + it('smoothly scrolls when the user sends a new message', () => { + useChatStore.setState({ + messages: [{ id: 'a1', role: 'assistant', type: 'text', content: 'answer', timestamp: 1 }], + isGenerating: false, + } as any) + render() + scrollIntoViewMock.mockClear() + + act(() => { + useChatStore.setState({ + messages: [ + { id: 'a1', role: 'assistant', type: 'text', content: 'answer', timestamp: 1 }, + { id: 'u1', role: 'user', type: 'text', content: 'follow-up', timestamp: 2 }, + ], + } as any) + }) + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' }) + }) + + it('keeps following generation when the user has not scrolled up', () => { + useChatStore.setState({ + messages: [{ id: 'u1', role: 'user', type: 'text', content: 'q', timestamp: 1 }], + isGenerating: false, + } as any) + render() + scrollIntoViewMock.mockClear() + + act(() => { + useChatStore.setState({ + messages: [ + { id: 'u1', role: 'user', type: 'text', content: 'q', timestamp: 1 }, + { id: 'a1', role: 'assistant', type: 'text', content: 'partial', timestamp: 2, streaming: true }, + ], + isGenerating: true, + } as any) + }) + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'instant' }) + }) }) diff --git a/web/src/components/chat/MessageList.tsx b/web/src/components/chat/MessageList.tsx index 52e107c1..e35398c1 100644 --- a/web/src/components/chat/MessageList.tsx +++ b/web/src/components/chat/MessageList.tsx @@ -63,9 +63,11 @@ export default function MessageList() { // 条件滚动到底部 useEffect(() => { + const prevMessagesLength = prevMessagesLengthRef.current const lastMsg = messages[messages.length - 1] + const loadedHistoryFromEmpty = prevMessagesLength === 0 && messages.length > 0 const userJustSent = - messages.length > prevMessagesLengthRef.current && lastMsg?.role === 'user' + prevMessagesLength > 0 && messages.length > prevMessagesLength && lastMsg?.role === 'user' prevMessagesLengthRef.current = messages.length const scrollEl = getScrollEl() @@ -85,6 +87,13 @@ export default function MessageList() { prevScrollTopRef.current = scrollTop } + // 历史会话首次加载完成时直接定位到底部,避免进入会话后停留在顶部。 + if (loadedHistoryFromEmpty) { + userScrolledUpRef.current = false + bottomRef.current?.scrollIntoView({ behavior: 'instant' }) + return + } + // 用户发送新消息时重置暂停状态,强制滚到底部 if (userJustSent) { userScrolledUpRef.current = false diff --git a/web/src/stores/useChatStore.test.ts b/web/src/stores/useChatStore.test.ts index 6325e9d3..6cb06763 100644 --- a/web/src/stores/useChatStore.test.ts +++ b/web/src/stores/useChatStore.test.ts @@ -30,6 +30,45 @@ describe('useChatStore', () => { expect(useChatStore.getState().messages[0].content).toBe('hello') }) + it('setMessages replaces messages atomically', () => { + const store = useChatStore.getState() + store.addMessage({ id: 'old', role: 'user', content: 'old', type: 'text', timestamp: 1 }) + + store.setMessages([ + { id: 'new-1', role: 'user', content: 'first', type: 'text', timestamp: 2 }, + { id: 'new-2', role: 'assistant', content: 'second', type: 'text', timestamp: 3 }, + ]) + + expect(useChatStore.getState().messages.map((m) => m.id)).toEqual(['new-1', 'new-2']) + }) + + it('setMessages preserves unrelated chat state', () => { + const store = useChatStore.getState() + store.setGenerating(true) + store.addPermissionRequest({ + request_id: 'r1', + tool_name: 'filesystem_read_file', + tool_category: 'filesystem', + action_type: 'read', + operation: 'read', + target_type: 'file', + target: 'README.md', + } as any) + store.updateTokenUsage({ input_tokens: 1, output_tokens: 2, total_tokens: 3 } as any) + store.setPhase('running') + store.setStopReason('manual') + + store.setMessages([{ id: 'hist', role: 'assistant', content: 'loaded', type: 'text', timestamp: 4 }]) + + const state = useChatStore.getState() + expect(state.messages.map((m) => m.id)).toEqual(['hist']) + expect(state.isGenerating).toBe(true) + expect(state.permissionRequests).toHaveLength(1) + expect(state.tokenUsage).toEqual({ input_tokens: 1, output_tokens: 2, total_tokens: 3 }) + expect(state.phase).toBe('running') + expect(state.stopReason).toBe('manual') + }) + it('appendChunk concatenates to streaming message', () => { const store = useChatStore.getState() store.addMessage({ id: 'stream-1', role: 'assistant', content: 'Hel', type: 'text', timestamp: 1 }) diff --git a/web/src/stores/useChatStore.ts b/web/src/stores/useChatStore.ts index b7126d25..9f79a1a2 100644 --- a/web/src/stores/useChatStore.ts +++ b/web/src/stores/useChatStore.ts @@ -72,6 +72,7 @@ interface ChatState { // Actions addMessage: (msg: ChatMessage) => void + setMessages: (messages: ChatMessage[]) => void removeMessage: (id: string) => void /** 从指定消息(含)开始截断 messages 数组并清理生成相关状态 */ truncateFromMessage: (messageId: string) => void @@ -199,6 +200,7 @@ export const useChatStore = create((set) => ({ permissionMode: 'default', addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })), + setMessages: (messages) => set({ messages: [...messages] }), removeMessage: (id) => set((s) => ({ messages: s.messages.filter((m) => m.id !== id) })), truncateFromMessage: (messageId) => diff --git a/web/src/stores/useSessionStore.test.ts b/web/src/stores/useSessionStore.test.ts index e2d2bd1f..cf706e42 100644 --- a/web/src/stores/useSessionStore.test.ts +++ b/web/src/stores/useSessionStore.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mapHistoryMessages, useSessionStore } from './useSessionStore' import { useChatStore } from './useChatStore' import { useGatewayStore } from './useGatewayStore' @@ -11,6 +11,10 @@ beforeEach(() => { useRuntimeInsightStore.getState().reset() }) +afterEach(() => { + vi.restoreAllMocks() +}) + describe('useSessionStore', () => { it('mapHistoryMessages skips internal system acceptance reminders', () => { const mapped = mapHistoryMessages([ @@ -115,6 +119,8 @@ describe('useSessionStore', () => { }) it('switchSession binds stream and loads session data', async () => { + const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages') + const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage') const mockBindStream = vi.fn().mockResolvedValue({}) const mockLoadSession = vi.fn().mockResolvedValue({ payload: { @@ -128,11 +134,15 @@ describe('useSessionStore', () => { await useSessionStore.getState().switchSession('sess-2', mockAPI) expect(mockBindStream).toHaveBeenCalledWith({ session_id: 'sess-2', channel: 'all' }) + expect(setMessagesSpy).toHaveBeenCalledTimes(1) + expect(addMessageSpy).not.toHaveBeenCalled() expect(useChatStore.getState().messages).toHaveLength(1) expect(useChatStore.getState().messages[0].role).toBe('user') }) it('fetchSessions auto-selects first session and binds stream', async () => { + const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages') + const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage') const mockListSessions = vi.fn().mockResolvedValue({ payload: { sessions: [{ @@ -144,13 +154,18 @@ describe('useSessionStore', () => { }, }) const mockBindStream = vi.fn().mockResolvedValue({}) - const mockLoadSession = vi.fn().mockResolvedValue({ payload: { messages: [] } }) + const mockLoadSession = vi.fn().mockResolvedValue({ + payload: { messages: [{ role: 'assistant', content: 'loaded history', tool_calls: [] }] }, + }) const mockAPI = { listSessions: mockListSessions, bindStream: mockBindStream, loadSession: mockLoadSession } as any await useSessionStore.getState().fetchSessions(mockAPI) expect(useSessionStore.getState().currentSessionId).toBe('sess-a') expect(mockBindStream).toHaveBeenCalledWith({ session_id: 'sess-a', channel: 'all' }) + expect(setMessagesSpy).toHaveBeenCalled() + expect(addMessageSpy).not.toHaveBeenCalled() + expect(useChatStore.getState().messages[0]).toMatchObject({ role: 'assistant', content: 'loaded history' }) }) it('fetchSessions does not auto-select when current session is valid', async () => { diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts index d7704304..91fd345d 100644 --- a/web/src/stores/useSessionStore.ts +++ b/web/src/stores/useSessionStore.ts @@ -256,9 +256,7 @@ export async function reloadSessionAfterCheckpointRestore( if (sessionData.messages && sessionData.messages.length > 0) { const mapped = mapHistoryMessages(sessionData.messages) - for (const msg of mapped) { - useChatStore.getState().addMessage(msg) - } + useChatStore.getState().setMessages(mapped) } const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build' @@ -323,9 +321,7 @@ export const useSessionStore = create((set, get) => ({ // 5. Load messages and stop transitioning if (sessionData.messages && sessionData.messages.length > 0) { const mapped = mapHistoryMessages(sessionData.messages) - for (const msg of mapped) { - useChatStore.getState().addMessage(msg) - } + useChatStore.getState().setMessages(mapped) } // 恢复会话的 agent_mode const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build' @@ -419,9 +415,7 @@ export const useSessionStore = create((set, get) => ({ const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string } if (sessionData.messages && sessionData.messages.length > 0) { const mapped = mapHistoryMessages(sessionData.messages) - for (const msg of mapped) { - useChatStore.getState().addMessage(msg) - } + useChatStore.getState().setMessages(mapped) } const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build' useChatStore.getState().setAgentMode(restoredMode) From 523a91c13e06f1f1c6ba20f2d3744026bdb3b780 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 11 May 2026 08:19:22 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat(web):=E7=8E=B0=E5=9C=A8compact?= =?UTF-8?q?=E6=9C=89=E7=8B=AC=E7=AB=8B=E7=9A=84=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/chat/ChatInput.test.tsx | 57 +++++++++++++++ web/src/components/chat/ChatInput.tsx | 51 ++++++++++---- web/src/index.css | 20 ++++++ web/src/stores/useChatStore.test.ts | 31 ++++++++ web/src/stores/useChatStore.ts | 32 +++++++++ web/src/utils/eventBridge.test.ts | 82 ++++++++++++++++++++++ web/src/utils/eventBridge.ts | 17 ++++- 7 files changed, 276 insertions(+), 14 deletions(-) diff --git a/web/src/components/chat/ChatInput.test.tsx b/web/src/components/chat/ChatInput.test.tsx index d4d797eb..599af2f3 100644 --- a/web/src/components/chat/ChatInput.test.tsx +++ b/web/src/components/chat/ChatInput.test.tsx @@ -73,6 +73,9 @@ describe('ChatInput', () => { useRuntimeInsightStore.getState().reset() useChatStore.setState({ isGenerating: false, + isCompacting: false, + compactMode: '', + compactMessage: '', messages: [], permissionRequests: [], agentMode: 'build', @@ -158,6 +161,60 @@ describe('ChatInput', () => { expect(screen.queryByTitle('附件文件')).not.toBeInTheDocument() expect(screen.queryByTitle('引用上下文')).not.toBeInTheDocument() }) + it('shows inline compact status while compaction is running', () => { + useChatStore.getState().startCompacting('manual', 'Compacting context...') + + render() + + expect(screen.getByRole('status')).toHaveTextContent('Compacting context...') + }) + + it('blocks normal sends while compaction is running', async () => { + useChatStore.getState().startCompacting('manual', 'Compacting context...') + render() + + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: 'hello' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + await waitFor(() => { + expect(mockGatewayAPI.run).not.toHaveBeenCalled() + }) + expect(useChatStore.getState().messages).toHaveLength(0) + }) + + it('blocks duplicate compact commands while compaction is running', async () => { + useSessionStore.setState({ currentSessionId: 'session-1' } as never) + useChatStore.getState().startCompacting('manual', 'Compacting context...') + render() + + await submitSlashCommand('/compact') + + await waitFor(() => { + expect(mockGatewayAPI.compact).not.toHaveBeenCalled() + }) + }) + + it('sets compact state immediately when running /compact', async () => { + useSessionStore.setState({ currentSessionId: 'session-1' } as never) + let resolveCompact: (value: unknown) => void = () => {} + mockGatewayAPI.compact.mockReturnValueOnce(new Promise((resolve) => { + resolveCompact = resolve + })) + render() + + await submitSlashCommand('/compact') + + await waitFor(() => { + expect(useChatStore.getState().isCompacting).toBe(true) + }) + + resolveCompact({}) + await waitFor(() => { + expect(useChatStore.getState().isCompacting).toBe(false) + }) + }) + it('executes /memo without session id and shows payload.Content', async () => { mockGatewayAPI.executeSystemTool.mockResolvedValueOnce({ payload: { diff --git a/web/src/components/chat/ChatInput.tsx b/web/src/components/chat/ChatInput.tsx index 5a4a172e..5669029f 100644 --- a/web/src/components/chat/ChatInput.tsx +++ b/web/src/components/chat/ChatInput.tsx @@ -19,7 +19,7 @@ import { import SlashCommandMenu from './SlashCommandMenu' import SkillPicker from './SkillPicker' import ModelSelector from './ModelSelector' -import { Send, Square } from 'lucide-react' +import { LoaderCircle, Send, Square } from 'lucide-react' const slashMenuAnchorStyle: React.CSSProperties = { position: 'absolute', @@ -129,6 +129,8 @@ export default function ChatInput() { const runCancelledRef = useRef(false) const composingRef = useRef(false) const isGenerating = useChatStore((state) => state.isGenerating) + const isCompacting = useChatStore((state) => state.isCompacting) + const compactMessage = useChatStore((state) => state.compactMessage) const addMessage = useChatStore((state) => state.addMessage) const addSystemMessage = useChatStore((state) => state.addSystemMessage) const setGenerating = useChatStore((state) => state.setGenerating) @@ -188,6 +190,10 @@ export default function ChatInput() { const { command, argument } = parsed const currentSessionId = sessionId const api = gatewayAPI + if (isCompacting) { + useUIStore.getState().showToast('Context compaction is still running', 'info') + return true + } if (!api) { useUIStore.getState().showToast('Gateway not connected', 'error') return true @@ -203,11 +209,17 @@ export default function ChatInput() { useUIStore.getState().showToast('Send a message first to start a session', 'error') return true } + useChatStore.getState().startCompacting('manual', 'Compacting context...') try { await api.compact(currentSessionId, '') } catch (err) { console.error('Compact failed:', err) - useUIStore.getState().showToast('Compaction failed', 'error') + if (useChatStore.getState().isCompacting) { + useChatStore.getState().finishCompacting() + useUIStore.getState().showToast('Compaction failed', 'error') + } + } finally { + useChatStore.getState().finishCompacting() } return true } @@ -261,8 +273,8 @@ export default function ChatInput() { return true } default: { - if (isGenerating) { - useUIStore.getState().showToast('Cannot toggle skill while generating', 'info') + if (isGenerating || isCompacting) { + useUIStore.getState().showToast(isCompacting ? 'Context compaction is still running' : 'Cannot toggle skill while generating', 'info') return true } const skillCommand = availableSkillCommands.find((skill) => skill.usage === command) @@ -287,12 +299,17 @@ export default function ChatInput() { return false } } - }, [gatewayAPI, sessionId, addSystemMessage, availableSkillCommands, isGenerating, allSlashCommands]) + }, [gatewayAPI, sessionId, addSystemMessage, availableSkillCommands, isGenerating, isCompacting, allSlashCommands]) async function handleSubmit() { const input = text.trim() if (!input) return + if (isCompacting) { + useUIStore.getState().showToast('Context compaction is still running', 'info') + return + } + if (isGenerating) { if (isSlashCommand(input)) useUIStore.getState().showToast('Cannot run commands while generating', 'info') return @@ -419,6 +436,7 @@ export default function ChatInput() { } const isEmpty = !text.trim() + const controlsLocked = isGenerating || isCompacting return ( <> @@ -438,7 +456,13 @@ export default function ChatInput() { />
)} -
+
+ {isCompacting && ( +
+ + {compactMessage || 'Compacting context...'} +
+ )}