mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-30 03:00:41 -04:00
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:
@@ -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 && (
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user