From 5010b502760c7b07d419d7b48301c4d026fda55e Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Wed, 28 Oct 2026 17:43:58 +0800 Subject: [PATCH 1/2] fix(web-ui): keep a submitted /compact out of the composer The desktop /compact entry point only settles after the compaction reaches a terminal state, so restoring the command text from the failure path put '/compact' back into the composer long after it was submitted - including when the compaction had already been applied and only its terminal bookkeeping failed. Treat the submitted command as committed: clear the composer through the submission contract (value, pending large pastes, machine queue, history cursor) and report a failure notification without refilling the composer. The outcome stays visible through the transcript turn and the compression card. --- .../src/flow_chat/components/ChatInput.tsx | 14 ++++++- .../ChatInputLocalCommandComposer.test.ts | 41 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 05ac1f817c..de93154426 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -3739,7 +3739,12 @@ export const ChatInput: React.FC = ({ return; } + setHistoryIndex(-1); + setSavedDraft(''); dispatchInput({ type: 'CLEAR_VALUE' }); + clearPendingLargePastes(); + // Clear the machine queue too; a stale queued value would be restored into + // the now-empty composer by the queuedInput sync effect. setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'all', query: '', selectedIndex: 0 }); @@ -3750,7 +3755,13 @@ export const ChatInput: React.FC = ({ error, sessionId: effectiveTargetSessionId, }); - dispatchInput({ type: 'SET_VALUE', payload: message }); + // A submitted command is not a draft. The desktop /compact entry point + // settles only after the compaction reaches a terminal state, so + // restoring the text here would put "/compact" back into the composer + // long after the user submitted it - including when the compaction was + // already applied and only its terminal bookkeeping failed. Report the + // failure instead; the composer stays cleared and the outcome stays in + // the transcript. notificationService.error( error instanceof Error ? error.message : t('error.unknown'), { @@ -3760,6 +3771,7 @@ export const ChatInput: React.FC = ({ ); } }, [ + clearPendingLargePastes, derivedState?.isProcessing, dispatchInput, effectiveTargetSession, diff --git a/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts b/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts new file mode 100644 index 0000000000..b0da6f4ca4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readChatInputComponent(): string { + return readFileSync(fileURLToPath(new URL('./ChatInput.tsx', import.meta.url)), 'utf8') + .replace(/\r\n/g, '\n'); +} + +function readCallbackBody(component: string, entry: string): string { + const start = component.indexOf(`const ${entry} = useCallback(`); + expect(start).toBeGreaterThan(-1); + return component.slice(start, component.indexOf(' }, [', start)); +} + +describe('composer ownership of local slash commands', () => { + const component = readChatInputComponent(); + + it('never restores a submitted /compact back into the composer', () => { + // The desktop /compact entry point only settles after the compaction + // reaches a terminal state, so a restore on failure repopulates the + // composer minutes later - sometimes after the compaction was applied and + // only its terminal bookkeeping failed. The submitted command text is not a + // draft: clear it once and report the failure instead of refilling it. + const body = readCallbackBody(component, 'submitCompactFromInput'); + + expect(body).toContain("dispatchInput({ type: 'CLEAR_VALUE' })"); + expect(body).not.toContain('SET_VALUE'); + expect(body).toContain('compactSession'); + expect(body.indexOf('compactSession')).toBeGreaterThan( + body.indexOf("dispatchInput({ type: 'CLEAR_VALUE' })"), + ); + }); + + it('clears the machine queue so the submission cannot be restored later', () => { + // The queuedInput sync effect restores queued text into an empty composer; + // a queue entry left behind by the command submission would resurface it. + const body = readCallbackBody(component, 'submitCompactFromInput'); + expect(body).toContain('setQueuedInput(null)'); + }); +}); From 5e9a5369b860a9379e3e848d774456e7c3a71c69 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Wed, 28 Oct 2026 17:49:47 +0800 Subject: [PATCH 2/2] fix(web-ui): keep an admitted /compact out of the composer An early rejection (session busy, context not loaded, agent unavailable) never starts a maintenance turn, so handing /compact back for a retry is still correct. Once the host admitted the compaction its terminal outcome already lives in the transcript - and may have applied before its bookkeeping failed - so refilling the composer there resurrects the command minutes later. Gate the restore on a maintenance turn that appeared after the submission. --- .../src/flow_chat/components/ChatInput.tsx | 28 ++++++++++++++----- .../components/ChatInputDraftRecovery.test.ts | 26 +++++++++++++++++ .../ChatInputLocalCommandComposer.test.ts | 20 ++++++------- .../components/chatInputDraftRecovery.ts | 21 ++++++++++++++ 4 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index de93154426..6386caa4d8 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -89,6 +89,7 @@ import { useAssistantBootstrap } from '@/app/hooks/useAssistantBootstrap'; import { clearComposerForSubmission, failedSubmissionRecoveryTarget, + manualCompactionTurnStarted, shouldRecordContextMutation, successfulRetryCleanupTarget, } from './chatInputDraftRecovery'; @@ -3739,6 +3740,13 @@ export const ChatInput: React.FC = ({ return; } + // Turns already visible before the submission; a maintenance turn that is + // not in this set proves the host admitted the compaction. + const knownTurnIds = new Set( + (FlowChatStore.getInstance().getState().sessions.get(effectiveTargetSessionId)?.dialogTurns ?? []) + .map(turn => turn.id), + ); + setHistoryIndex(-1); setSavedDraft(''); dispatchInput({ type: 'CLEAR_VALUE' }); @@ -3755,13 +3763,19 @@ export const ChatInput: React.FC = ({ error, sessionId: effectiveTargetSessionId, }); - // A submitted command is not a draft. The desktop /compact entry point - // settles only after the compaction reaches a terminal state, so - // restoring the text here would put "/compact" back into the composer - // long after the user submitted it - including when the compaction was - // already applied and only its terminal bookkeeping failed. Report the - // failure instead; the composer stays cleared and the outcome stays in - // the transcript. + // A command the host admitted is not a draft. The desktop /compact entry + // point settles only after the compaction reaches a terminal state, so + // restoring that text would put "/compact" back into the composer long + // after the user submitted it - including when the compaction was already + // applied and only its terminal bookkeeping failed. Only an early + // rejection, which never started a maintenance turn, hands the command + // back for a retry. + const session = FlowChatStore.getInstance() + .getState() + .sessions.get(effectiveTargetSessionId); + if (!manualCompactionTurnStarted(knownTurnIds, session?.dialogTurns ?? [])) { + dispatchInput({ type: 'SET_VALUE', payload: message }); + } notificationService.error( error instanceof Error ? error.message : t('error.unknown'), { diff --git a/src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts b/src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts index 9835fd1bad..e725a52756 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { clearComposerForSubmission, failedSubmissionRecoveryTarget, + manualCompactionTurnStarted, shouldRecordContextMutation, successfulRetryCleanupTarget, } from './chatInputDraftRecovery'; @@ -77,3 +78,28 @@ describe('ChatInput failed submission recovery', () => { )).toBe('none'); }); }); + +describe('manual compaction admission', () => { + const turn = (id: string, kind?: string) => ({ id, kind }); + + it('keeps a command retryable while no maintenance turn exists', () => { + expect(manualCompactionTurnStarted( + new Set(['turn-1']), + [turn('turn-1', 'user_dialog')], + )).toBe(false); + }); + + it('detects the maintenance turn the host created for the submission', () => { + expect(manualCompactionTurnStarted( + new Set(['turn-1']), + [turn('turn-1', 'user_dialog'), turn('turn-2', 'manual_compaction')], + )).toBe(true); + }); + + it('ignores a compaction turn left by an earlier submission', () => { + expect(manualCompactionTurnStarted( + new Set(['turn-1', 'turn-2']), + [turn('turn-1', 'manual_compaction'), turn('turn-2', 'user_dialog')], + )).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts b/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts index b0da6f4ca4..d534bed3b4 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts @@ -16,19 +16,19 @@ function readCallbackBody(component: string, entry: string): string { describe('composer ownership of local slash commands', () => { const component = readChatInputComponent(); - it('never restores a submitted /compact back into the composer', () => { - // The desktop /compact entry point only settles after the compaction - // reaches a terminal state, so a restore on failure repopulates the - // composer minutes later - sometimes after the compaction was applied and - // only its terminal bookkeeping failed. The submitted command text is not a - // draft: clear it once and report the failure instead of refilling it. + it('hands /compact back only when the host never admitted it', () => { + // The desktop /compact entry point settles only after the compaction + // reaches a terminal state. An admitted compaction already reported its + // outcome in the transcript (and may have applied before failing), so + // refilling the composer there puts "/compact" back minutes later. Only an + // early rejection - no maintenance turn - is a retryable draft. const body = readCallbackBody(component, 'submitCompactFromInput'); expect(body).toContain("dispatchInput({ type: 'CLEAR_VALUE' })"); - expect(body).not.toContain('SET_VALUE'); - expect(body).toContain('compactSession'); - expect(body.indexOf('compactSession')).toBeGreaterThan( - body.indexOf("dispatchInput({ type: 'CLEAR_VALUE' })"), + expect(body.indexOf('const knownTurnIds')).toBeGreaterThan(-1); + expect(body.indexOf('const knownTurnIds')).toBeLessThan(body.indexOf('compactSession')); + expect(body).toMatch( + /if \(!manualCompactionTurnStarted\(knownTurnIds,[\s\S]*?dispatchInput\(\{ type: 'SET_VALUE', payload: message \}\);\s*\n\s*\}/, ); }); diff --git a/src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts b/src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts index db902e99da..7235086854 100644 --- a/src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts +++ b/src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts @@ -38,6 +38,27 @@ export function shouldRecordContextMutation( return contextsChanged && !isRestoringSessionDraft; } +interface CommandTurnIdentity { + id: string; + kind?: string; +} + +/** + * Whether the host admitted a manual compaction for a command submission. + * + * A command that failed before admission never produced a turn, so its text is + * still a retryable draft. Once the maintenance turn exists the outcome already + * reached the transcript - and the compaction may even have been applied before + * its terminal bookkeeping failed - so refilling the composer would resurrect + * the submitted command long after the fact. + */ +export function manualCompactionTurnStarted( + knownTurnIds: ReadonlySet, + turns: readonly CommandTurnIdentity[], +): boolean { + return turns.some(turn => turn.kind === 'manual_compaction' && !knownTurnIds.has(turn.id)); +} + export function successfulRetryCleanupTarget( submissionSessionId: string, currentSessionId: string | null,