fix(copilot): disable decompose_goal Approve/Modify while message is streaming

After the build plan box appears, the assistant continues streaming a
short summary text. Clicking Approve or Modify in that 1-2s window failed
because the chat session is locked to the in-flight turn — sending a new
user message gets rejected.

- ChatMessagesContainer now forwards isCurrentlyStreaming through
  renderSegments → MessagePartRenderer → DecomposeGoalTool.
- DecomposeGoalTool computes actionsEnabled = showActions && !streaming
  and uses it to (a) disable the Approve, Modify, and timer buttons and
  (b) gate the auto-approve effect so the timer can hit 0 mid-stream
  without firing — the effect re-runs and approves once streaming ends.
- The countdown ring keeps ticking during streaming so it stays in sync
  with the server-side timer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
anvyle
2026-04-10 18:49:17 +02:00
parent f7601d06ed
commit f467ead855
3 changed files with 45 additions and 8 deletions

View File

@@ -51,6 +51,7 @@ function renderSegments(
messageID: string,
onRetry?: () => void,
isLastMessage?: boolean,
isMessageStreaming?: boolean,
): React.ReactNode[] {
return segments.map((seg, segIdx) => {
if (seg.kind === "collapsed-group") {
@@ -64,6 +65,7 @@ function renderSegments(
partIndex={seg.index}
onRetry={onRetry}
isLastMessage={isLastMessage}
isMessageStreaming={isMessageStreaming}
/>
);
});
@@ -375,6 +377,7 @@ export function ChatMessagesContainer({
message.id,
isLastAssistant ? onRetry : undefined,
isLastAssistant,
isCurrentlyStreaming,
)
: message.parts.map((part, i) => (
<MessagePartRenderer
@@ -384,6 +387,7 @@ export function ChatMessagesContainer({
partIndex={i}
onRetry={isLastAssistant ? onRetry : undefined}
isLastMessage={isLastAssistant}
isMessageStreaming={isCurrentlyStreaming}
/>
))}
{isLastInTurn && !isCurrentlyStreaming && (

View File

@@ -95,6 +95,7 @@ interface Props {
partIndex: number;
onRetry?: () => void;
isLastMessage?: boolean;
isMessageStreaming?: boolean;
}
export function MessagePartRenderer({
@@ -103,6 +104,7 @@ export function MessagePartRenderer({
partIndex,
onRetry,
isLastMessage,
isMessageStreaming,
}: Props) {
const key = `${messageID}-${partIndex}`;
@@ -176,6 +178,7 @@ export function MessagePartRenderer({
key={key}
part={part as ToolUIPart}
isLastMessage={isLastMessage}
isMessageStreaming={isMessageStreaming}
/>
);
case "tool-create_agent":

View File

@@ -68,9 +68,17 @@ interface EditableStep {
interface Props {
part: ToolUIPart;
isLastMessage?: boolean;
// True while the parent assistant message is still streaming. We disable
// Approve/Modify in this window because the chat session is locked to
// the in-flight turn — sending a new user message would fail.
isMessageStreaming?: boolean;
}
export function DecomposeGoalTool({ part, isLastMessage }: Props) {
export function DecomposeGoalTool({
part,
isLastMessage,
isMessageStreaming,
}: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
@@ -88,6 +96,12 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) {
isDecompositionOutput(output) &&
output.requires_approval;
// The Approve/Modify buttons are visible (so the user knows what's
// coming) but click-disabled while the assistant is still streaming
// its summary text after the tool call. The countdown ring keeps
// ticking so it stays in sync with the server-side timer.
const actionsEnabled = showActions && !isMessageStreaming;
// Authoritative countdown comes from the backend tool response so the
// server-side fallback timer and the client are guaranteed to agree.
const countdownSeconds =
@@ -181,14 +195,16 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) {
return () => clearInterval(interval);
}, [showActions, timerActive, part.toolCallId]);
// Auto-approve when countdown reaches 0 (only if timer is still active and actions visible).
// showActions prevents firing after isLastMessage changes (race condition guard).
// Auto-approve when countdown reaches 0 — but only after the assistant
// has finished streaming its summary text. Firing during streaming would
// hit the same locked-session failure as a manual click. If the timer
// hits 0 mid-stream, this effect re-runs when actionsEnabled flips true.
// approve() is stable via approvedRef — safe to omit from deps.
useEffect(() => {
if (secondsLeft === 0 && timerActive && showActions) {
if (secondsLeft === 0 && timerActive && actionsEnabled) {
approve();
}
}, [secondsLeft, timerActive, showActions]); // approve reads refs only — safe to omit
}, [secondsLeft, timerActive, actionsEnabled]); // approve reads refs only — safe to omit
const progress = secondsLeft / countdownSeconds;
const dashOffset = CIRCUMFERENCE * (1 - progress);
@@ -292,7 +308,11 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) {
{showActions && (
<div className="flex items-center gap-2 pt-1">
{isEditing ? (
<Button variant="primary" onClick={approve}>
<Button
variant="primary"
onClick={approve}
disabled={!actionsEnabled}
>
<span className="inline-flex items-center gap-1.5">
<CheckIcon size={14} weight="bold" />
Approve
@@ -301,7 +321,12 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) {
) : (
<>
{/* Primary CTA — encourages user to run the agent */}
<Button variant="primary" size="small" onClick={approve}>
<Button
variant="primary"
size="small"
onClick={approve}
disabled={!actionsEnabled}
>
<span className="group/label inline-flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 group-hover/label:hidden">
Starting in
@@ -344,7 +369,12 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) {
</span>
</span>
</Button>
<Button variant="ghost" size="small" onClick={handleModify}>
<Button
variant="ghost"
size="small"
onClick={handleModify}
disabled={!actionsEnabled}
>
<span className="inline-flex items-center gap-1.5">
<PencilSimpleIcon size={14} weight="bold" />
Modify