From 37378b5caca86d9775728e9e71ea43fcdeb77758 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Thu, 5 Nov 2026 20:15:46 +0800 Subject: [PATCH] feat(web-ui): let /compact carry the message to submit afterwards A manual compaction used to reject everything after the command, so the next message could only be sent once the compaction had finished and the user typed it again. `/compact ` now keeps the trailing text as a follow-up and submits it through the normal send path after `compact_session` settles: the desktop command returns when the maintenance turn reaches its terminal state, so the follow-up turn reads the compressed context instead of racing it. It is queued rather than steered, because the terminal bookkeeping can still be settling at that point. A manual compaction without trailing text, and every automatic compaction, submit nothing extra. Closes #3739 --- .../src/flow_chat/components/ChatInput.tsx | 67 ++++++++++++++++--- .../ChatInputCompactFollowUp.test.ts | 57 ++++++++++++++++ .../flow_chat/utils/compactCommand.test.ts | 28 ++++++++ .../src/flow_chat/utils/compactCommand.ts | 29 ++++++++ src/web-ui/src/locales/en-US/flow-chat.json | 3 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 3 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 3 +- 7 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/ChatInputCompactFollowUp.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/compactCommand.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/compactCommand.ts diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index a2add38c86..326d7b2ebc 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -66,6 +66,7 @@ import { type SlashActionId, } from '../utils/slashActionSelection'; import { parseReloadCommand, supportsLocalReloadContext } from '../utils/reloadCommand'; +import { parseCompactCommand } from '../utils/compactCommand'; import { reviewPromptCommandShell } from '../utils/promptCommandShellReview'; import { notificationService } from '@/shared/notification-system'; import { useI18n } from '@/infrastructure/i18n'; @@ -3798,23 +3799,31 @@ export const ChatInput: React.FC = ({ } const message = inputState.value.trim(); - if (!/^\/compact\s*$/i.test(message)) { + const compactRequest = parseCompactCommand(message); + if (!compactRequest) { notificationService.warning( t('chatInput.compactUsage') ); return; } + // Resolve the follow-up while its placeholders are still staged: large + // pastes and composer reference tokens only expand from the current draft. + const followUp = compactRequest.followUp + ? expandComposerSpecialTokens(compactRequest.followUp) + : ''; + dispatchInput({ type: 'CLEAR_VALUE' }); setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'all', query: '', selectedIndex: 0 }); + const sessionId = effectiveTargetSessionId; try { - await FlowChatManager.getInstance().compactSession(effectiveTargetSessionId); + await FlowChatManager.getInstance().compactSession(sessionId); } catch (error) { log.error('Failed to trigger /compact', { error, - sessionId: effectiveTargetSessionId, + sessionId, }); dispatchInput({ type: 'SET_VALUE', payload: message }); notificationService.error( @@ -3824,13 +3833,56 @@ export const ChatInput: React.FC = ({ duration: 5000, } ); + return; + } + + if (!followUp) { + return; + } + + /* + * A manual `/compact ` submits that message only once the + * compaction has landed, so the turn it starts reads the compressed context + * instead of racing the maintenance turn. Automatic compaction never comes + * through the composer, so it keeps submitting nothing. + * + * Never steer: the compaction's terminal bookkeeping can still be settling + * here, and the follow-up belongs to the turn after it. Queuing lets the + * host or the local drain send it as soon as the session is idle again. The + * composer keeps its attachments too; this send owns the follow-up text, and + * anything the user attached while compacting belongs to their next message. + */ + try { + await sendMessage(followUp, { + clearContextsOnSuccess: false, + sendImmediately: false, + }); + } catch (error) { + log.error('Failed to submit the message sent with /compact', { + error, + sessionId, + }); + // The follow-up never became a turn, so hand it back as a draft unless + // the user has already started writing something else. + if (!inputValueRef.current.trim()) { + dispatchInput({ type: 'SET_VALUE', payload: followUp }); + } + notificationService.error( + error instanceof Error ? error.message : t('error.unknown'), + { + title: t('chatInput.compactFollowUpFailed'), + duration: 5000, + } + ); } }, [ derivedState?.isProcessing, dispatchInput, effectiveTargetSession, effectiveTargetSessionId, + expandComposerSpecialTokens, inputState.value, + sendMessage, setQueuedInput, t, ]); @@ -5422,7 +5474,7 @@ export const ChatInput: React.FC = ({ return; } - if (promptSlashCommandsEnabled && caps.ops.has('compact') && /^\/compact\s*$/i.test(message)) { + if (promptSlashCommandsEnabled && caps.ops.has('compact') && parseCompactCommand(message)) { await submitCompactFromInput(); return; } @@ -5456,13 +5508,6 @@ export const ChatInput: React.FC = ({ return; } - if (promptSlashCommandsEnabled && caps.ops.has('compact') && isSlashCommand(message, '/compact')) { - notificationService.warning( - t('chatInput.compactUsage') - ); - return; - } - if (promptSlashCommandsEnabled && caps.ops.has('usage') && isSlashCommand(message, '/usage')) { notificationService.warning( t('chatInput.usageCommandUsage') diff --git a/src/web-ui/src/flow_chat/components/ChatInputCompactFollowUp.test.ts b/src/web-ui/src/flow_chat/components/ChatInputCompactFollowUp.test.ts new file mode 100644 index 0000000000..fd587e6593 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputCompactFollowUp.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * Composer wiring for `/compact `. + * + * A mounted composer cannot be driven through a real compaction in a unit + * test, so these assertions lock the two properties that make the feature + * work: the command admits a follow-up, and the follow-up is only submitted + * after the compaction finished. Runtime behaviour still needs a manual pass + * against a desktop build. + */ +function readChatInput(): string { + return readFileSync( + fileURLToPath(new URL('./ChatInput.tsx', import.meta.url)), + 'utf8', + ).replace(/\r\n/g, '\n'); +} + +function readSubmitCompactBody(source: string): string { + const start = source.indexOf('const submitCompactFromInput = useCallback('); + expect(start).toBeGreaterThan(-1); + const end = source.indexOf('\n ]);', start); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe('/compact follow-up submission', () => { + it('routes every /compact submission through the parser that keeps a follow-up', () => { + const source = readChatInput(); + + expect(source).toContain("caps.ops.has('compact') && parseCompactCommand(message)"); + // The old all-or-nothing gates rejected or warned on any extra argument. + expect(source).not.toContain('isSlashCommand(message, \'/compact\')'); + }); + + it('submits the follow-up only after the compaction has settled', () => { + const body = readSubmitCompactBody(readChatInput()); + const compactionAt = body.indexOf('compactSession(sessionId)'); + const followUpAt = body.indexOf('sendMessage(followUp'); + + expect(compactionAt).toBeGreaterThan(-1); + expect(followUpAt).toBeGreaterThan(compactionAt); + // Queueing instead of steering keeps the message out of the compaction turn + // while its terminal bookkeeping is still settling. + expect(body).toContain('sendImmediately: false'); + }); + + it('returns the dropped follow-up as a draft without overwriting newer input', () => { + const body = readSubmitCompactBody(readChatInput()); + + expect(body).toContain('if (!inputValueRef.current.trim()) {'); + expect(body).toContain("dispatchInput({ type: 'SET_VALUE', payload: followUp })"); + expect(body).toContain("title: t('chatInput.compactFollowUpFailed')"); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/compactCommand.test.ts b/src/web-ui/src/flow_chat/utils/compactCommand.test.ts new file mode 100644 index 0000000000..8041dfcae3 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/compactCommand.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { parseCompactCommand } from './compactCommand'; + +describe('parseCompactCommand', () => { + it('accepts a bare command', () => { + expect(parseCompactCommand('/compact')).toEqual({ followUp: '' }); + expect(parseCompactCommand(' /COMPACT ')).toEqual({ followUp: '' }); + }); + + it('keeps everything after the command as the follow-up message', () => { + expect(parseCompactCommand('/compact continue the fix')) + .toEqual({ followUp: 'continue the fix' }); + expect(parseCompactCommand('/compact first line\nsecond line ')) + .toEqual({ followUp: 'first line\nsecond line' }); + }); + + it('preserves the original casing and markdown of the follow-up', () => { + expect(parseCompactCommand('/compact Review `Foo.tsx` next')) + .toEqual({ followUp: 'Review `Foo.tsx` next' }); + }); + + it('leaves unrelated prompts on the ordinary message path', () => { + expect(parseCompactCommand('/compacted')).toBeNull(); + expect(parseCompactCommand('/compact-run')).toBeNull(); + expect(parseCompactCommand('please /compact')).toBeNull(); + expect(parseCompactCommand('')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/compactCommand.ts b/src/web-ui/src/flow_chat/utils/compactCommand.ts new file mode 100644 index 0000000000..844a9824cb --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/compactCommand.ts @@ -0,0 +1,29 @@ +import { matchesSlashCommand, stripSlashCommand } from './slashCommand'; + +/** + * A manual `/compact` submission. + * + * The command owns the composer text it was submitted with, so anything the + * user typed after `/compact` is the message to submit once the compaction + * finishes. Auto-triggered compaction never goes through the composer and + * therefore never carries a follow-up. + */ +export interface CompactCommandRequest { + /** Message to submit after the compaction completes; empty when there is none. */ + followUp: string; +} + +/** + * Parse a composer submission as `/compact [followUp]`. + * + * Returns `null` for any other prompt, so the caller can fall through to the + * ordinary message path. + */ +export function parseCompactCommand(input: string): CompactCommandRequest | null { + const text = input.trim(); + if (matchesSlashCommand(text) !== '/compact') { + return null; + } + + return { followUp: stripSlashCommand(text, '/compact').trim() }; +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 43311b2eb8..15d79670f8 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1101,8 +1101,9 @@ "compactAction": "Compact session", "compactNoSession": "No active session for /compact", "compactBusy": "Wait until the session is idle before using /compact.", - "compactUsage": "Use /compact without extra arguments.", + "compactUsage": "Use /compact, optionally followed by the message to submit after compacting.", "compactFailed": "Session compaction failed", + "compactFollowUpFailed": "The message after /compact was not sent", "usageAction": "Session usage", "usageNoSession": "No active session for /usage", "usageCommandUsage": "Use /usage without extra arguments.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 361fca49b7..3e636e2e66 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1101,8 +1101,9 @@ "compactAction": "压缩会话", "compactNoSession": "当前没有可用于 /compact 的会话", "compactBusy": "请等待当前会话空闲后再使用 /compact。", - "compactUsage": "请直接使用 /compact,不要附加额外参数。", + "compactUsage": "使用 /compact,可在其后附加压缩完成后要发送的消息。", "compactFailed": "会话压缩失败", + "compactFollowUpFailed": "压缩后附加的消息未能发送", "goalAction": "会话目标", "goalAchieved": "会话目标已达成", "goalVerifyFailed": "目标验证失败,请检查模型配置后重试。", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index d4b9319926..ac00d91e07 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1101,8 +1101,9 @@ "compactAction": "壓縮會話", "compactNoSession": "目前沒有可用於 /compact 的會話", "compactBusy": "請等待目前會話空閒後再使用 /compact。", - "compactUsage": "請直接使用 /compact,不要附加額外參數。", + "compactUsage": "使用 /compact,可在其後附加壓縮完成後要傳送的訊息。", "compactFailed": "會話壓縮失敗", + "compactFollowUpFailed": "壓縮後附加的訊息未能傳送", "goalAction": "會話目標", "goalAchieved": "會話目標已達成", "goalVerifyFailed": "目標驗證失敗,請檢查模型設定後重試。",