From 57d07b7fcf22d3c196315279031db352d3649052 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:36:41 +0000 Subject: [PATCH] fix(web): report rejected goal starts without losing drafts --- .../FastSessionTranscript.client.test.tsx | 75 +++++++++++++++++++ .../[sessionId]/SessionPromptInput.tsx | 26 +++++-- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 8ff384d95..e4f6d5f76 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -9,6 +9,7 @@ import { waitFor, } from '@testing-library/react'; import { useState } from 'react'; +import { toast } from 'sonner'; import { ACP_ENVELOPE_EVENT_TYPES, SETUP_RECEIPT_INPUT_KIND, @@ -5502,4 +5503,78 @@ describe('FastSessionTranscript', () => { ); expect(replyMutate).not.toHaveBeenCalled(); }); + + it.each([ + [new Error('Failed to fetch'), 'Failed to fetch'], + [null, 'Try again shortly.'], + ])( + 'reports a rejected goal start and retains the same draft for retry (%s)', + async (error, description) => { + const toastError = vi.spyOn(toast, 'error'); + try { + startGoalMutate.mockRejectedValueOnce(error); + render( + , + ); + + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { + target: { value: '/goal ship the release' }, + }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => + expect(toastError).toHaveBeenCalledWith('Could not start the goal.', { + description, + }), + ); + expect(input).toHaveValue('/goal ship the release'); + expect(replyMutate).not.toHaveBeenCalled(); + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + await waitFor(() => expect(startGoalMutate).toHaveBeenCalledTimes(2)); + expect(startGoalMutate).toHaveBeenLastCalledWith({ + sessionId: 'session-1', + objective: 'ship the release', + }); + await waitFor(() => expect(input).toHaveValue('')); + expect(replyMutate).not.toHaveBeenCalled(); + } finally { + toastError.mockRestore(); + } + }, + ); + + it('keeps a handled goal-start error and its draft instead of showing transport feedback', async () => { + const toastError = vi.spyOn(toast, 'error'); + try { + startGoalMutate.mockResolvedValueOnce({ + success: false, + error: 'A goal is already active.', + }); + render( + , + ); + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: '/goal ship the release' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => + expect(toastError).toHaveBeenCalledWith('A goal is already active.'), + ); + expect(toastError).toHaveBeenCalledTimes(1); + expect(input).toHaveValue('/goal ship the release'); + expect(replyMutate).not.toHaveBeenCalled(); + } finally { + toastError.mockRestore(); + } + }); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx index 270fec598..0cf8850e3 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx @@ -389,16 +389,26 @@ export function SessionPromptInput({ toast.error('Goal Mode does not support attachments.'); return false; } - const result = await trpcClient.fastSessions.startGoal.mutate({ - sessionId, - objective, - }); - if (!result.success) { - toast.error(result.error); + try { + const result = await trpcClient.fastSessions.startGoal.mutate({ + sessionId, + objective, + }); + if (!result.success) { + toast.error(result.error); + return false; + } + toast.success(`Pursuing goal: ${objective}`); + sent = true; + } catch (error) { + toast.error('Could not start the goal.', { + description: + error instanceof Error && error.message + ? error.message + : 'Try again shortly.', + }); return false; } - toast.success(`Pursuing goal: ${objective}`); - sent = true; } else { // Always send the current picker state: it round-trips the persisted // choice and clears it when the picker is reset to the default.