fix: make plan approval an inline chat card

This commit is contained in:
Luis Esteban Acevedo Ladino 2026-05-01 19:03:47 -05:00
parent 3e912159e1
commit cea87833e4
7 changed files with 170 additions and 194 deletions

View File

@ -20,8 +20,7 @@ You are in PLAN mode. You CANNOT make any edits to the timeline. You can only:
1. **Analyze**: Use read-only tools to understand what media and timeline state exist. Use load_context to actually see/hear the footage.
2. **Discover**: If the user's request matches a known editing pattern, call list_skills to find relevant techniques.
3. **Clarify**: If anything is ambiguous, use ask_user to get clarification before planning.
4. **Plan**: Once you understand the footage and the goal, call submit_plan with a structured step-by-step plan.
5. **Approval**: Immediately after submit_plan succeeds, call request_plan_approval. Do NOT edit until the user approves.
4. **Plan**: Once you understand the footage and the goal, call submit_plan with a structured step-by-step plan. This shows the user a plan card with Go edit / Keep planning and waits for their choice.
## PLAN QUALITY RULES:
@ -38,7 +37,7 @@ Submit the plan when:
- All clarifying questions have been answered
- You have a concrete, actionable plan
After submitting, you MUST ask for approval through request_plan_approval. The user will choose Keep planning or Go edit.
After submit_plan returns approved=true, continue with the plan in execute mode. If it returns approved=false, keep discussing and refining the plan.
`;
const EXECUTE_MODE_INSTRUCTIONS = `
@ -52,8 +51,7 @@ For any complex editing request that would require multiple timeline edits (cuts
1. Analyze the project with read-only tools.
2. Call submit_plan with a concise structured checklist.
3. Call request_plan_approval.
4. Wait for the user to choose Go edit.
3. Wait for the user to choose Go edit.
Do NOT perform write tools for complex edits before approval. This is required even if the current mode is EXECUTE.
@ -67,7 +65,7 @@ Do NOT perform write tools for complex edits before approval. This is required e
## IF NO ACTIVE PLAN:
Simple one-shot edits may execute directly. Complex edits must go through submit_plan + request_plan_approval first.
Simple one-shot edits may execute directly. Complex edits must go through submit_plan first.
`;
export function buildSystemPrompt(

View File

@ -308,7 +308,7 @@ export const loadSkillSchema: ToolSchema = {
export const submitPlanSchema: ToolSchema = {
name: "submit_plan",
description:
"Submits a structured editing plan with steps. Use this before complex edits, even from execute mode. Each step should describe a specific action with the tools to use. After submission, call request_plan_approval and wait for the user before any write tools.",
"Submits a structured editing plan with steps and waits for user approval through a plan card with Go edit and Keep planning actions. Use this before complex edits, even from execute mode. Each step should describe a specific action with the tools to use. If approved=true is returned, continue executing the plan. If approved=false is returned, keep refining the plan with the user.",
parameters: [
{ key: "summary", type: "string", required: true },
{ key: "steps", type: "array", required: true },
@ -329,7 +329,7 @@ export const askUserSchema: ToolSchema = {
export const requestPlanApprovalSchema: ToolSchema = {
name: "request_plan_approval",
description:
"Requests user approval to switch from planning to editing. The user sees a modal with Keep planning and Go edit options. ALWAYS requires explicit user approval — there is no auto-approve. Use immediately after submit_plan succeeds.",
"Requests user approval to switch from planning to editing for an already submitted plan. Usually you do not need this because submit_plan already waits for approval. The user sees a plan card with Keep planning and Go edit options.",
parameters: [],
};

View File

@ -3,6 +3,7 @@ import { toolRegistry } from "@/agent/tools/registry";
import { submitPlanSchema } from "@/agent/tools/schemas";
import { usePlanStore, createPlanFromSteps } from "@/stores/plan-store";
import { useAgentStore } from "@/stores/agent-store";
import type { AgentMode } from "@/agent/types";
type SubmittedStep = { description: string; tools: string[] };
@ -15,7 +16,8 @@ const submitPlanTool: ToolDefinition = {
| {
planId: string;
stepCount: number;
nextAction: "request_plan_approval";
approved: boolean;
mode: AgentMode;
}
| { error: string }
> => {
@ -49,11 +51,27 @@ const submitPlanTool: ToolDefinition = {
usePlanStore.getState().setPlan(plan);
useAgentStore.getState().setMode("plan");
return {
planId: plan.id,
stepCount: plan.steps.length,
nextAction: "request_plan_approval",
};
return new Promise((resolve) => {
useAgentStore.getState().setPendingModeTransition({
targetMode: "execute",
resolve: (approved) => {
useAgentStore.getState().setPendingModeTransition(null);
if (approved) {
useAgentStore.getState().setMode("execute");
usePlanStore.getState().updatePlanStatus("executing");
} else {
useAgentStore.getState().setMode("plan");
usePlanStore.getState().updatePlanStatus("awaiting_approval");
}
resolve({
planId: plan.id,
stepCount: plan.steps.length,
approved,
mode: approved ? "execute" : "plan",
});
},
});
});
},
};

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, type KeyboardEvent } from "react";
import { useState, useCallback, useRef, type KeyboardEvent } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Sent02Icon,
@ -12,9 +12,7 @@ import {
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useAgentStore, type PermissionMode } from "@/stores/agent-store";
import { usePlanStore } from "@/stores/plan-store";
import { cn } from "@/utils/ui";
import type { AgentMode } from "@/agent/types";
interface ChatInputProps {
onSend: (content: string) => void;
@ -23,46 +21,48 @@ interface ChatInputProps {
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [value, setValue] = useState("");
const shiftHandledRef = useRef(false);
const permissionMode = useAgentStore((s) => s.permissionMode);
const setPermissionMode = useAgentStore((s) => s.setPermissionMode);
const pendingApproval = useAgentStore((s) => s.pendingApproval);
const mode = useAgentStore((s) => s.mode);
const setMode = useAgentStore((s) => s.setMode);
const pendingTransition = useAgentStore((s) => s.pendingModeTransition);
const plan = usePlanStore((s) => s.plan);
const isPlanMode = mode === "plan";
const toggleMode = useCallback(() => {
setMode(isPlanMode ? "execute" : "plan");
}, [isPlanMode, setMode]);
const handleSend = useCallback(
(forcedMode?: AgentMode) => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
const targetMode = forcedMode ?? mode;
if (targetMode !== mode) {
setMode(targetMode);
}
onSend(trimmed);
setValue("");
},
[value, disabled, onSend, mode, setMode],
);
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
}, [value, disabled, onSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Shift") {
if (!shiftHandledRef.current) {
shiftHandledRef.current = true;
toggleMode();
}
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend("execute");
}
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
handleSend("plan");
handleSend();
}
},
[handleSend],
[handleSend, toggleMode],
);
const toggleMode = useCallback(() => {
const next: AgentMode = mode === "execute" ? "plan" : "execute";
setMode(next);
}, [mode, setMode]);
const handleKeyUp = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Shift") {
shiftHandledRef.current = false;
}
}, []);
const togglePermission = useCallback(() => {
const next: PermissionMode = permissionMode === "skip" ? "ask" : "skip";
@ -70,15 +70,36 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
}, [permissionMode, setPermissionMode]);
const isAsk = permissionMode === "ask";
const isPlanMode = mode === "plan";
return (
<div className="border-t p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<button
type="button"
onClick={toggleMode}
disabled={!!pendingApproval || !!pendingTransition}
className={cn(
"bg-primary/10 text-primary border-primary/25 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<HugeiconsIcon
icon={isPlanMode ? CheckListIcon : Edit02Icon}
className="size-3.5"
strokeWidth={2}
/>
{isPlanMode ? "Plan mode" : "Edit mode"}
</button>
<span className="text-muted-foreground text-[10px]">
Press Shift to switch
</span>
</div>
<div className="flex items-end gap-2">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
disabled={disabled || !!pendingTransition}
placeholder={
isPlanMode
@ -88,24 +109,6 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
rows={1}
className="border-border bg-input focus-visible:border-primary/50 focus-visible:ring-primary/20 flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
variant={isPlanMode ? "default" : "secondary"}
size="icon"
onClick={toggleMode}
disabled={!!pendingApproval || !!pendingTransition}
aria-label={isPlanMode ? "Plan mode active" : "Switch to plan mode"}
title={
isPlanMode
? "Plan mode (Shift+Enter to send in plan mode)"
: "Edit mode (Enter to send)"
}
>
<HugeiconsIcon
icon={isPlanMode ? CheckListIcon : Edit02Icon}
className={cn("size-4", isPlanMode && "text-blue-400")}
strokeWidth={1.5}
/>
</Button>
<Button
variant={isAsk ? "default" : "secondary"}
size="icon"
@ -123,7 +126,7 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
<Button
variant="secondary"
size="icon"
onClick={() => handleSend()}
onClick={handleSend}
disabled={disabled || !value.trim() || !!pendingTransition}
aria-label="Send message"
>
@ -134,21 +137,6 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
)}
</Button>
</div>
<div className="mt-1.5 flex items-center justify-between">
<span className="text-muted-foreground text-[10px]">
Enter = edit mode <span className="text-muted-foreground/60">|</span>{" "}
Shift+Enter = plan mode
</span>
{plan && (
<span className="text-[10px] tabular-nums">
<span
className={cn(isPlanMode ? "text-blue-400" : "text-emerald-400")}
>
{isPlanMode ? "PLANNING" : "EXECUTING"}
</span>
</span>
)}
</div>
</div>
);
}

View File

@ -8,13 +8,11 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useChatStore } from "@/stores/chat-store";
import { useAgentStore } from "@/stores/agent-store";
import { usePlanStore } from "@/stores/plan-store";
import { MessageBubble } from "./message-bubble";
import { ChatInput } from "./chat-input";
import { ToolPermissionRequest } from "./tool-permission-request";
import { PlanPanel } from "./plan-panel";
import { ModeTransitionModal } from "./mode-transition-modal";
import { AskUserPrompt } from "./ask-user-prompt";
import { PlanApprovalCard } from "./plan-approval-card";
import { run as orchestratorRun } from "@/agent/orchestrator";
import { EditorContextAdapter } from "@/agent/context";
@ -25,8 +23,6 @@ export function ChatPanel() {
const sendMessage = useChatStore((s) => s.sendMessage);
const setError = useChatStore((s) => s.setError);
const pendingApproval = useAgentStore((s) => s.pendingApproval);
const pendingTransition = useAgentStore((s) => s.pendingModeTransition);
const plan = usePlanStore((s) => s.plan);
const scrollRef = useRef<HTMLDivElement>(null);
@ -56,12 +52,6 @@ export function ChatPanel() {
return (
<div className="flex h-full flex-col">
{plan && (
<div className="max-h-48 shrink-0 overflow-hidden border-b">
<PlanPanel />
</div>
)}
{messages.length === 0 && !loading && !error ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
@ -90,6 +80,7 @@ export function ChatPanel() {
</span>
</div>
)}
<PlanApprovalCard />
<AskUserPrompt />
{pendingApproval && (
<ToolPermissionRequest pending={pendingApproval} />
@ -121,8 +112,6 @@ export function ChatPanel() {
)}
<ChatInput onSend={handleSend} disabled={loading} />
{pendingTransition && <ModeTransitionModal />}
</div>
);
}

View File

@ -1,105 +0,0 @@
"use client";
import { useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { CheckListIcon, Edit02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { useAgentStore } from "@/stores/agent-store";
import { usePlanStore } from "@/stores/plan-store";
import { cn } from "@/utils/ui";
export function ModeTransitionModal() {
const pendingTransition = useAgentStore((s) => s.pendingModeTransition);
const plan = usePlanStore((s) => s.plan);
const handleContinuePlanning = useCallback(() => {
if (!pendingTransition) return;
pendingTransition.resolve(false);
}, [pendingTransition]);
const handleStartEditing = useCallback(() => {
if (!pendingTransition) return;
pendingTransition.resolve(true);
}, [pendingTransition]);
if (!pendingTransition) return null;
const doneCount = plan?.steps.filter((s) => s.status === "done").length ?? 0;
const totalCount = plan?.steps.length ?? 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-background mx-4 max-w-sm rounded-lg border p-5 shadow-xl">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<h3 className="text-sm font-semibold">Plan ready for execution</h3>
{plan && (
<p className="text-muted-foreground text-xs">
{plan.summary} {totalCount} step{totalCount !== 1 ? "s" : ""}
{doneCount > 0 && ` (${doneCount} already done)`}
</p>
)}
</div>
{plan && (
<div className="bg-muted/50 max-h-40 overflow-auto rounded-md p-2">
<ul className="flex flex-col gap-1">
{plan.steps.map((step) => (
<li
key={step.id}
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
step.status === "done"
? "bg-emerald-400"
: step.status === "skipped"
? "bg-muted-foreground/30"
: "bg-muted-foreground/60",
)}
/>
<span
className={cn(step.status === "done" && "line-through")}
>
{step.description}
</span>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-col gap-2">
<Button
variant="default"
size="sm"
onClick={handleStartEditing}
className="w-full"
>
<HugeiconsIcon
icon={Edit02Icon}
className="mr-2 size-4"
strokeWidth={1.5}
/>
Go edit
</Button>
<Button
variant="outline"
size="sm"
onClick={handleContinuePlanning}
className="w-full"
>
<HugeiconsIcon
icon={CheckListIcon}
className="mr-2 size-4"
strokeWidth={1.5}
/>
Keep planning
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
"use client";
import { useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import {
CheckListIcon,
Edit02Icon,
ReloadIcon,
} from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { useAgentStore } from "@/stores/agent-store";
import { usePlanStore } from "@/stores/plan-store";
export function PlanApprovalCard() {
const pendingTransition = useAgentStore((s) => s.pendingModeTransition);
const plan = usePlanStore((s) => s.plan);
const handleKeepPlanning = useCallback(() => {
if (!pendingTransition) return;
pendingTransition.resolve(false);
}, [pendingTransition]);
const handleGoEdit = useCallback(() => {
if (!pendingTransition) return;
pendingTransition.resolve(true);
}, [pendingTransition]);
if (!pendingTransition || !plan) return null;
return (
<div className="border-primary/30 bg-primary/5 flex flex-col gap-3 rounded-lg border p-3 shadow-sm">
<div className="flex items-start gap-2">
<div className="bg-primary/15 text-primary mt-0.5 rounded-md p-1.5">
<HugeiconsIcon
icon={CheckListIcon}
className="size-4"
strokeWidth={2}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold">Plan ready</span>
<span className="text-primary text-[10px] font-medium uppercase tracking-wide">
Plan mode
</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
{plan.summary}
</p>
</div>
</div>
<div className="bg-background/70 rounded-md border p-2">
<ul className="flex flex-col gap-1.5">
{plan.steps.map((step, index) => (
<li key={step.id} className="flex items-start gap-2 text-xs">
<span className="bg-primary/10 text-primary mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-medium">
{index + 1}
</span>
<span className="leading-relaxed">{step.description}</span>
</li>
))}
</ul>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleGoEdit} className="h-8 flex-1 text-xs">
<HugeiconsIcon icon={Edit02Icon} className="mr-1.5 size-3.5" />
Go edit
</Button>
<Button
variant="outline"
size="sm"
onClick={handleKeepPlanning}
className="h-8 flex-1 text-xs"
>
<HugeiconsIcon icon={ReloadIcon} className="mr-1.5 size-3.5" />
Keep planning
</Button>
</div>
<p className="text-muted-foreground text-[10px]">
Go edit switches to edit mode and lets the agent execute the plan. Keep
planning keeps writes blocked.
</p>
</div>
);
}