From ba1a7ffcf597a4f103d49ad089d8b07c4fa0e351 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 12 Sep 2026 01:50:04 +0800 Subject: [PATCH] fix(web-shell): suppress abort toasts on preflight rejection Route the two preflight-failure toast sites in App.tsx through reportError instead of hand-rolling pushToast('error', formatError(...)). reportError already suppresses AbortError, daemon-turn errors and already-dispatched notices, so a daemon teardown that rejects a submission with an AbortError now stays silent instead of surfacing a "failed" toast for a submission the user cancelled. reportError is hoisted above sendPrompt/enqueuePrompt (it previously sat below both, which would be a TDZ error for their dep arrays), and the now-redundant console.warn in each preflight catch is dropped to avoid double-logging a real failure (reportError already logs via console.error). Also documents the prepareSubmit / onSubmitBefore contract: a rejection's Error.message is shown to the user, so hosts must localize it. Fixes #11432 Co-authored-by: Qwen-Coder Patrol-Run: qwen-issue-patrol/jmtx7igejyc --- packages/web-shell/client/App.test.tsx | 40 +++++++++++++ packages/web-shell/client/App.tsx | 82 +++++++++++++------------- 2 files changed, 80 insertions(+), 42 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 121a8e51cdf..90916c08823 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -21732,6 +21732,46 @@ describe('App session callbacks', () => { ); }); + // #11432: a daemon teardown rejects the preflight with an AbortError, which + // is a cancellation rather than a failure. reportError suppresses aborts, so + // no toast should surface — unlike a real (localized) rejection above. + it('stays silent when preparation rejects with an AbortError', async () => { + const abortError = new Error('cancelled'); + abortError.name = 'AbortError'; + const prepareSubmit = vi.fn().mockRejectedValue(abortError); + const onToast = vi.fn(); + const { container } = renderApp({ prepareSubmit, onToast }); + await flush(); + + await clickSubmit(container); + await flush(); + + expect(prepareSubmit).toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(onToast).not.toHaveBeenCalled(); + }); + + it('stays silent when a queued preparation rejects with an AbortError', async () => { + const abortError = new Error('cancelled'); + abortError.name = 'AbortError'; + const prepareSubmit = vi.fn().mockRejectedValue(abortError); + const onToast = vi.fn(); + const { container, rerender } = renderApp({ prepareSubmit, onToast }); + await flush(); + + act(() => { + testState.streamingState = 'responding'; + rerender({ prepareSubmit, onToast }); + }); + + await clickSubmit(container); + await flush(); + + expect(prepareSubmit).toHaveBeenCalled(); + expect(rawEnqueuePrompt).not.toHaveBeenCalled(); + expect(onToast).not.toHaveBeenCalled(); + }); + it('keeps the draft when preparation removes all prompt content', async () => { const prepareSubmit = vi.fn().mockResolvedValue({ prompt: '', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 803eca3bcfb..d0fa84ea908 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1377,6 +1377,8 @@ export interface WebShellProps { * direct or queued logical submit, after local command routing and before * session creation, composer commit, optimistic rendering, or admission. * Retries reuse the previously prepared payload and skip this callback. + * If this callback rejects, the submission is cancelled and the rejection's + * `Error.message` is surfaced to the user, so hosts must localize it. */ prepareSubmit?: ( submission: WebShellSubmitSnapshot, @@ -1386,6 +1388,8 @@ export interface WebShellProps { * until the Promise resolves. If the Promise rejects, the prompt is cancelled. * `sessionId` is `undefined` when the session has not yet been created (deferred). * Also called for queued prompts (submitted while a turn is streaming). + * A rejection's `Error.message` is surfaced to the user, so hosts must + * localize it. */ onSubmitBefore?: (params: { sessionId: string | undefined; @@ -9710,6 +9714,25 @@ export function App({ // to be recreated on every render, cascading into downstream effect chains. const dispatchSessionChangeRef = useRef(dispatchSessionChange); dispatchSessionChangeRef.current = dispatchSessionChange; + // Single error-to-toast helper: suppresses aborts, daemon-turn errors and + // already-dispatched notices before surfacing anything. Declared above + // sendPrompt / enqueuePrompt so their dep arrays can reference it (a + // reference below those callbacks would be a TDZ error). + const reportError = useCallback( + (error: unknown, fallback: string) => { + if (isAbortError(error)) return; + if (isDaemonTurnError(error)) { + return; + } + if (isAlreadyDispatched(error)) { + return; + } + const message = formatError(error, fallback); + console.error('[web-shell]', message, error); + pushToast('error', message); + }, + [pushToast], + ); const sendPrompt = useCallback( async ( text: string, @@ -9828,21 +9851,16 @@ export function App({ } } catch (err) { if (!appMountedRef.current) return; - console.warn( - '[web-shell] prompt preflight rejected, prompt cancelled', - err, - ); // Say so. Hosts put user-facing text in these errors — the VS Code // companion's message-edit rewind throws localized failures here — - // and cancelling on a console warning alone leaves the user in front - // of a composer that appeared to do nothing (#9911). Only when the - // user is still on the session this submission belonged to; a toast - // for a session they have left would be noise. + // and cancelling silently leaves the user in front of a composer + // that appeared to do nothing (#9911). Only when the user is still + // on the session this submission belonged to; a toast for a session + // they have left would be noise. reportError suppresses aborts and + // logs the error itself, so this catch no longer warns (that would + // double-log a real failure). if (admissionOwnerIsCurrent()) { - pushToast( - 'error', - formatError(err, 'Message could not be submitted'), - ); + reportError(err, 'Message could not be submitted'); } // Restore retry-critical refs so Ctrl+Y doesn't resend the // cancelled prompt. @@ -10046,7 +10064,7 @@ export function App({ ensureSessionForPrompt, finishPromptPreparation, getComposerWorkspaceCwd, - pushToast, + reportError, sessionCatalogController, sessionActions, sessionOwnerGuard, @@ -10306,21 +10324,6 @@ export function App({ ]), ); - const reportError = useCallback( - (error: unknown, fallback: string) => { - if (isAbortError(error)) return; - if (isDaemonTurnError(error)) { - return; - } - if (isAlreadyDispatched(error)) { - return; - } - const message = formatError(error, fallback); - console.error('[web-shell]', message, error); - pushToast('error', message); - }, - [pushToast], - ); const handleFailedPromptRetry = useCallback(() => { if ( sessionWriteBlockedRef.current || @@ -10629,22 +10632,17 @@ export function App({ sourceWorkspaceCwd, ); } catch (err) { - console.warn( - '[web-shell] queued prompt preflight rejected, cancelled', - err, - ); // A rejected preflight cancels the submission, so it has to say so. // Hosts put user-facing text in these errors — the VS Code - // companion's rewind failures are localized strings — and a console - // warning leaves the user in front of a composer that silently did - // nothing (#9911). Stay quiet only when this submission is no - // longer the current one, where the toast would belong to a session - // the user has already left. + // companion's rewind failures are localized strings — and a silent + // cancel leaves the user in front of a composer that did nothing + // (#9911). Stay quiet only when this submission is no longer the + // current one, where the toast would belong to a session the user + // has already left. reportError suppresses aborts and logs the + // error itself, so this catch no longer warns (that would + // double-log a real failure). if (submissionSessionIsCurrent()) { - pushToast( - 'error', - formatError(err, 'Message could not be submitted'), - ); + reportError(err, 'Message could not be submitted'); } } })(); @@ -10661,7 +10659,7 @@ export function App({ }, [ getComposerWorkspaceCwd, - pushToast, + reportError, rawEnqueuePrompt, sessionCatalogController, sessionOwnerGuard,