本轮 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() {
/>