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.