feat(copilot): add auto-approve timer with editable steps to decompose_goal UI

- Replace static Approve/Modify buttons with a 99s countdown timer that
  auto-approves when it expires
- Timer ring animates inline within "Starting in [N]s" text using SVG
  strokeDasharray; hover on the text swaps it to "Start now" via Tailwind
  named groups (group/label)
- Clicking Modify stops the timer, enters editable mode where steps can be
  renamed, deleted, or inserted between existing steps
- In edit mode only Approve is shown; timer and Modify are hidden
- showActions gated on isLastMessage (server-derived) so the timer never
  re-appears when returning to a session with prior messages
- Forward isLastMessage through ChatMessagesContainer → MessagePartRenderer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
anvyle
2026-04-09 21:50:43 +02:00
parent aca81f3e40
commit 5fa33111de
3 changed files with 252 additions and 40 deletions

View File

@@ -50,6 +50,7 @@ function renderSegments(
segments: RenderSegment[],
messageID: string,
onRetry?: () => void,
isLastMessage?: boolean,
): React.ReactNode[] {
return segments.map((seg, segIdx) => {
if (seg.kind === "collapsed-group") {
@@ -62,6 +63,7 @@ function renderSegments(
messageID={messageID}
partIndex={seg.index}
onRetry={onRetry}
isLastMessage={isLastMessage}
/>
);
});
@@ -372,6 +374,7 @@ export function ChatMessagesContainer({
responseSegments,
message.id,
isLastAssistant ? onRetry : undefined,
isLastAssistant,
)
: message.parts.map((part, i) => (
<MessagePartRenderer
@@ -380,6 +383,7 @@ export function ChatMessagesContainer({
messageID={message.id}
partIndex={i}
onRetry={isLastAssistant ? onRetry : undefined}
isLastMessage={isLastAssistant}
/>
))}
{isLastInTurn && !isCurrentlyStreaming && (

View File

@@ -94,6 +94,7 @@ interface Props {
messageID: string;
partIndex: number;
onRetry?: () => void;
isLastMessage?: boolean;
}
export function MessagePartRenderer({
@@ -101,6 +102,7 @@ export function MessagePartRenderer({
messageID,
partIndex,
onRetry,
isLastMessage,
}: Props) {
const key = `${messageID}-${partIndex}`;
@@ -169,7 +171,13 @@ export function MessagePartRenderer({
case "tool-schedule_agent":
return <RunAgentTool key={key} part={part as ToolUIPart} />;
case "tool-decompose_goal":
return <DecomposeGoalTool key={key} part={part as ToolUIPart} />;
return (
<DecomposeGoalTool
key={key}
part={part as ToolUIPart}
isLastMessage={isLastMessage}
/>
);
case "tool-create_agent":
return <CreateAgentTool key={key} part={part as ToolUIPart} />;
case "tool-edit_agent":

View File

@@ -1,13 +1,18 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { CheckIcon, PencilSimpleIcon } from "@phosphor-icons/react";
import {
CheckIcon,
PencilSimpleIcon,
PlusIcon,
TrashIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { useEffect, useRef, useState } from "react";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import {
ContentGrid,
ContentHint,
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
@@ -22,11 +27,24 @@ import {
ToolIcon,
} from "./helpers";
interface Props {
part: ToolUIPart;
const COUNTDOWN_SECONDS = 99;
const RADIUS = 15;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
interface EditableStep {
step_id: string;
description: string;
action: string;
block_name?: string | null;
status: string;
}
export function DecomposeGoalTool({ part }: Props) {
interface Props {
part: ToolUIPart;
isLastMessage?: boolean;
}
export function DecomposeGoalTool({ part, isLastMessage }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
@@ -34,20 +52,102 @@ export function DecomposeGoalTool({ part }: Props) {
part.state === "input-streaming" || part.state === "input-available";
const output = getDecomposeGoalOutput(part);
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const isOperating = !output;
function handleApprove() {
onSend("Approved. Please build the agent.");
const showActions =
!!isLastMessage &&
!!output &&
isDecompositionOutput(output) &&
output.requires_approval;
const [secondsLeft, setSecondsLeft] = useState(COUNTDOWN_SECONDS);
// timerActive becomes false when the user clicks Modify — stops countdown and auto-approve.
const [timerActive, setTimerActive] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [editableSteps, setEditableSteps] = useState<EditableStep[]>([]);
const approvedRef = useRef(false);
const onSendRef = useRef(onSend);
const isEditingRef = useRef(isEditing);
const editableStepsRef = useRef(editableSteps);
onSendRef.current = onSend;
isEditingRef.current = isEditing;
editableStepsRef.current = editableSteps;
function buildMessage() {
if (isEditingRef.current && editableStepsRef.current.length > 0) {
const list = editableStepsRef.current
.map((s, i) => `${i + 1}. ${s.description}`)
.join("; ");
return `Approved with modifications. Please build the agent following these steps: ${list}`;
}
return "Approved. Please build the agent.";
}
function approve() {
if (approvedRef.current) return;
approvedRef.current = true;
setIsEditing(false);
onSendRef.current(buildMessage());
}
function handleModify() {
onSend("I'd like to modify the plan. Here are my changes: ");
if (!output || !isDecompositionOutput(output)) return;
setTimerActive(false);
setIsEditing(true);
setEditableSteps(output.steps.map((s) => ({ ...s })));
}
function handleStepChange(index: number, description: string) {
setEditableSteps((prev) =>
prev.map((s, i) => (i === index ? { ...s, description } : s)),
);
}
function handleStepDelete(index: number) {
setEditableSteps((prev) => prev.filter((_, i) => i !== index));
}
// Insert a blank step after the given index (-1 = prepend).
function handleStepInsert(afterIndex: number) {
setEditableSteps((prev) => {
const next = [...prev];
next.splice(afterIndex + 1, 0, {
step_id: `step_new_${Date.now()}`,
description: "",
action: "add_block",
status: "pending",
});
return next;
});
}
// Tick down only while the timer is active.
useEffect(() => {
if (!showActions || !timerActive) return;
const interval = setInterval(() => {
setSecondsLeft((s) => Math.max(0, s - 1));
}, 1000);
return () => clearInterval(interval);
}, [showActions, timerActive, part.toolCallId]);
// Auto-approve when countdown reaches 0 (only if timer is still active).
useEffect(() => {
if (secondsLeft === 0 && timerActive) approve();
// approve is stable via ref — intentionally omitted
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [secondsLeft, timerActive]);
const progress = secondsLeft / COUNTDOWN_SECONDS;
const dashOffset = CIRCUMFERENCE * (1 - progress);
const stepCount = isEditing
? editableSteps.length
: output && isDecompositionOutput(output)
? output.step_count
: 0;
return (
<div className="py-2">
{isOperating && (
@@ -76,49 +176,149 @@ export function DecomposeGoalTool({ part }: Props) {
{output && isDecompositionOutput(output) && (
<ToolAccordion
icon={<AccordionIcon />}
title={`Build Plan — ${output.step_count} steps`}
title={`Build Plan — ${stepCount} steps`}
description={output.goal}
defaultExpanded
>
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
<div className="space-y-0.5 rounded-lg border border-slate-200 bg-white p-3">
{output.steps.map((step, i) => (
<StepItem
key={step.step_id}
index={i}
description={step.description}
action={step.action}
blockName={step.block_name}
status={step.status}
/>
))}
<div className="rounded-lg border border-slate-200 bg-white p-3">
{isEditing ? (
<div className="flex flex-col">
{/* Insert before the first step */}
<InsertButton onClick={() => handleStepInsert(-1)} />
{editableSteps.map((step, i) => (
<div key={step.step_id} className="flex flex-col">
<div className="flex items-center gap-2 py-1">
<span className="w-5 shrink-0 text-xs text-slate-400">
{i + 1}.
</span>
<input
type="text"
value={step.description}
onChange={(e) => handleStepChange(i, e.target.value)}
className="flex-1 rounded border border-slate-200 px-2 py-1 text-sm focus:border-neutral-400 focus:outline-none"
placeholder="Step description"
/>
<button
type="button"
onClick={() => handleStepDelete(i)}
className="text-slate-400 hover:text-red-500"
aria-label="Remove step"
>
<TrashIcon size={14} />
</button>
</div>
{/* Insert after each step */}
<InsertButton onClick={() => handleStepInsert(i)} />
</div>
))}
</div>
) : (
<div className="space-y-0.5">
{output.steps.map((step, i) => (
<StepItem
key={step.step_id}
index={i}
description={step.description}
action={step.action}
blockName={step.block_name}
status={step.status}
/>
))}
</div>
)}
</div>
{output.requires_approval && (
{showActions && (
<div className="flex items-center gap-2 pt-1">
<Button variant="primary" onClick={handleApprove}>
<span className="inline-flex items-center gap-1.5">
<CheckIcon size={14} weight="bold" />
Approve
</span>
</Button>
<Button variant="ghost" onClick={handleModify}>
<span className="inline-flex items-center gap-1.5">
<PencilSimpleIcon size={14} weight="bold" />
Modify
</span>
</Button>
{isEditing ? (
<Button variant="primary" onClick={approve}>
<span className="inline-flex items-center gap-1.5">
<CheckIcon size={14} weight="bold" />
Approve
</span>
</Button>
) : (
<>
{/* Timer button — same ghost style as Modify, ring wraps the number inline */}
<Button variant="ghost" onClick={approve}>
<span className="group/label inline-flex items-center gap-1">
<span className="inline-flex items-center gap-1 group-hover/label:hidden">
Starting in
<span className="relative inline-flex h-5 w-5 items-center justify-center">
<svg
width="20"
height="20"
viewBox="0 0 34 34"
className="absolute -rotate-90"
>
<circle
cx="17"
cy="17"
r={RADIUS}
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-neutral-300"
/>
<circle
cx="17"
cy="17"
r={RADIUS}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
className="text-neutral-600 transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
</svg>
<span className="relative z-10 text-[9px] font-semibold tabular-nums text-neutral-700">
{secondsLeft}
</span>
</span>
s
</span>
<span className="hidden group-hover/label:inline">
Start now
</span>
</span>
</Button>
<span className="text-neutral-300">|</span>
<Button variant="ghost" onClick={handleModify}>
<span className="inline-flex items-center gap-1.5">
<PencilSimpleIcon size={14} weight="bold" />
Modify
</span>
</Button>
</>
)}
</div>
)}
<ContentHint>
Review the plan above and approve to start building.
</ContentHint>
</ContentGrid>
</ToolAccordion>
)}
</div>
);
}
function InsertButton({ onClick }: { onClick: () => void }) {
return (
<div className="group flex items-center gap-1 py-0.5">
<div className="h-px flex-1 bg-slate-100 group-hover:bg-neutral-300" />
<button
type="button"
onClick={onClick}
className="flex items-center gap-0.5 rounded px-1 text-xs text-slate-300 hover:text-neutral-700 focus:outline-none"
aria-label="Insert step here"
>
<PlusIcon size={10} weight="bold" />
</button>
<div className="h-px flex-1 bg-slate-100 group-hover:bg-neutral-300" />
</div>
);
}