Skip to content

Commit 7e6d495

Browse files
committed
Composer: re-enable Stop when a cancel request itself fails
1 parent 2a512a5 commit 7e6d495

2 files changed

Lines changed: 28 additions & 12 deletions

File tree

‎packages/chat-ui/src/chat-workspace.tsx‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -812,15 +812,19 @@ function ChatWorkspaceInner({
812812
[tenantId, activeWorkbenchId],
813813
);
814814

815-
// CL-7201: fire-and-forget, matching the reaction/pin handlers above —
816-
// the composer's own `stopping` state is the user-visible feedback,
817-
// and the timeline's cancelled-turn notice (not this response) is
818-
// what actually clears the typing indicator once the turn settles.
815+
// CL-7201: unlike the reaction/pin handlers above, this rethrows after
816+
// toasting — the composer's own `onStop` awaits the returned promise
817+
// and re-enables its Stop button on rejection, so a genuinely failed
818+
// request (network, a denied grant) never leaves the button stuck
819+
// disabled for the rest of the turn. The timeline's cancelled-turn
820+
// notice, not this response, is what actually clears the typing
821+
// indicator once (or if) the turn settles.
819822
const handleStopTurn = useCallback(() => {
820-
if (activeWorkbenchId === null) return;
821-
cancelWorkbenchTurn(tenantId, activeWorkbenchId).catch(() =>
822-
toast(CHAT_STRINGS.turnCancelError),
823-
);
823+
if (activeWorkbenchId === null) return Promise.resolve();
824+
return cancelWorkbenchTurn(tenantId, activeWorkbenchId).catch((err) => {
825+
toast(CHAT_STRINGS.turnCancelError);
826+
throw err;
827+
});
824828
}, [tenantId, activeWorkbenchId]);
825829

826830
const handlePinMessage = useCallback(

‎packages/chat-ui/src/composer.tsx‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,16 @@ export const Composer = forwardRef<
351351
* typically the host's own `isPendingReply(streamingReply)`. Absent
352352
* or `false` renders no Stop affordance at all. */
353353
readonly running?: boolean;
354-
/** Cancels the running turn — `POST .../turns/cancel`. Required
354+
/**
355+
* Cancels the running turn — `POST .../turns/cancel`. Required
355356
* whenever `running` can be `true`; the composer never guesses at
356-
* how to stop a turn on its own. */
357-
readonly onStop?: () => void;
357+
* how to stop a turn on its own. May return a promise: a rejection
358+
* re-enables the button immediately (the request itself failed —
359+
* network, a denied grant — not merely a slow cancel, so there is
360+
* no reason to make the person wait for `running` to change before
361+
* they can try again).
362+
*/
363+
readonly onStop?: () => void | Promise<unknown>;
358364
}
359365
>(function Composer(
360366
{
@@ -737,7 +743,13 @@ export const Composer = forwardRef<
737743
function handleStop() {
738744
if (stopping || onStop === undefined) return;
739745
setStopping(true);
740-
onStop();
746+
// CL-7201 (Critique finding): a rejected stop request is a FAILED
747+
// cancel, not a slow one -- the `useEffect` above only re-enables
748+
// once the host reports `running` has gone false, which never
749+
// happens for a request that never reached the server. Without
750+
// this catch the button stayed disabled for the rest of the turn's
751+
// life with no way to retry.
752+
Promise.resolve(onStop()).catch(() => setStopping(false));
741753
}
742754

743755
return (

0 commit comments

Comments
 (0)