"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { ChevronLeft, ChevronRight, Hand, HelpCircle, Lock, Pause, Play, RotateCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { ApprovalCard, Controls, MandateCard, SetupForm, StepLog, SummaryCard } from "./agent-panel";
import { PRESETS, PRODUCTS, SLOTS, TODAY } from "./data";
import { createLocalAgent, INITIAL_STORE, labelFor, makeMoney, observe, storeClick, storeField, totals, urlOf, type StoreCtx } from "./engine";
import { GhostCursor, type CursorState } from "./ghost-cursor";
import { Storefront } from "./storefront";
import type { AgentAction, AgentFn, AgentStep, LogEntry, Mandate, Observation, Order, Preset, Product, RunStatus, StoreState } from "./types";
import { AgentMark, ROOT_VARS, focusRing, useMedia } from "./ui";
export type { AgentFn, AgentStep, AgentAction, Observation, Mandate, Product, Order, Preset };
export type ShoppingAgentAppProps = {
/** Store catalogue. Default: 36 seeded grocery & home products. */
products?: Product[];
presets?: Preset[];
/** Initial mandate; default is the first preset. */
initialMandate?: Mandate;
storeName?: string;
agentName?: string;
currency?: string;
locale?: string;
/** "Today" (YYYY-MM-DD) for delivery dates. */
today?: string;
/**
* Plug in a real model: called once per step with a structured page snapshot, the cart and history.
* Return the next action. Default: a deterministic local policy.
*/
agent?: AgentFn;
onStep?: (step: AgentStep, obs: Observation) => void;
onApprove?: (amount: number) => void;
onDecline?: () => void;
onOrder?: (order: Order) => void;
speed?: 1 | 2;
autoRun?: boolean;
className?: string;
};
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const frames = (n = 2) => new Promise<void>((r) => {
const tick = (k: number) => (k <= 0 ? r() : requestAnimationFrame(() => tick(k - 1)));
tick(n);
});
const STATUS_LABEL: Record<RunStatus, string> = {
idle: "Ready",
running: "Running",
paused: "Paused",
takeover: "You're driving",
approval: "Needs approval",
done: "Done",
declined: "Declined",
blocked: "Blocked",
failed: "Failed",
stopped: "Stopped",
};
export function ShoppingAgentApp({
products = PRODUCTS,
presets = PRESETS,
initialMandate,
storeName = "Orbit Market",
agentName = "Errand",
currency = "PLN",
locale = "pl-PL",
today = TODAY,
agent,
onStep,
onApprove,
onDecline,
onOrder,
speed: speedProp = 1,
autoRun = false,
className,
}: ShoppingAgentAppProps) {
const reduced = useReducedMotion() ?? false;
const money = React.useMemo(() => makeMoney(currency, locale), [currency, locale]);
const byId = React.useMemo(() => new Map(products.map((p) => [p.id, p])), [products]);
const wide = useMedia("(min-width: 1024px)");
const first = initialMandate ?? presets[0] ?? { task: "", cap: 100, deadline: null, askBeforePay: true };
const [mandate, setMandate] = React.useState<Mandate>({ task: first.task, cap: first.cap, deadline: first.deadline, askBeforePay: first.askBeforePay });
const [presetId, setPresetId] = React.useState<string | null>(initialMandate ? null : presets[0]?.id ?? null);
const [store, setStore] = React.useState<StoreState>(INITIAL_STORE);
const [status, setStatus] = React.useState<RunStatus>("idle");
const [log, setLog] = React.useState<LogEntry[]>([]);
const [cursor, setCursor] = React.useState<CursorState>({ x: 200, y: 160, visible: false, click: 0, typing: false, label: agentName });
const [target, setTarget] = React.useState<string | null>(null);
const [speed, setSpeed] = React.useState<1 | 2>(speedProp);
const [approval, setApproval] = React.useState<number | null>(null);
const [result, setResult] = React.useState<{ message: string; order: Order | null; seconds: number } | null>(null);
const [tab, setTab] = React.useState<"agent" | "store">("agent");
const [help, setHelp] = React.useState(false);
const [announce, setAnnounce] = React.useState("");
const ctx: StoreCtx = React.useMemo(() => ({ products, byId, today, deadline: mandate.deadline }), [products, byId, today, mandate.deadline]);
const storeRef = React.useRef(store);
const statusRef = React.useRef(status);
const ctxRef = React.useRef(ctx);
const mandateRef = React.useRef(mandate);
const speedRef = React.useRef(speed);
const reducedRef = React.useRef(reduced);
const runRef = React.useRef(0);
const historyRef = React.useRef<AgentStep[]>([]);
const agentRef = React.useRef<AgentFn | null>(null);
const stepOnceRef = React.useRef(false);
const startedRef = React.useRef(0);
const frameRef = React.useRef<HTMLDivElement>(null);
const storeScrollRef = React.useRef<HTMLDivElement>(null);
const panelScrollRef = React.useRef<HTMLDivElement>(null);
const callbacks = React.useRef({ onStep, onApprove, onDecline, onOrder });
React.useEffect(() => {
ctxRef.current = ctx;
mandateRef.current = mandate;
speedRef.current = speed;
reducedRef.current = reduced;
callbacks.current = { onStep, onApprove, onDecline, onOrder };
});
const applyStore = React.useCallback((next: StoreState) => {
storeRef.current = next;
setStore(next);
}, []);
const setStatusBoth = React.useCallback((s: RunStatus) => {
statusRef.current = s;
setStatus(s);
}, []);
const patchLog = (id: string, p: Partial<LogEntry>) => setLog((l) => l.map((e) => (e.id === id ? { ...e, ...p } : e)));
/* ------------------------------ agent actions ----------------------------- */
const moveTo = React.useCallback(async (id: string) => {
const frame = frameRef.current;
let el = frame?.querySelector<HTMLElement>(`[data-agent-id="${CSS.escape(id)}"]`);
if (!el) {
await frames(3);
el = frame?.querySelector<HTMLElement>(`[data-agent-id="${CSS.escape(id)}"]`);
}
if (!el || !frame) return false;
const r0 = el.getBoundingClientRect();
const sc = storeScrollRef.current;
if (sc && sc.contains(el)) {
const box = sc.getBoundingClientRect();
if (r0.top < box.top + 8 || r0.bottom > box.bottom - 8) {
sc.scrollTo({ top: sc.scrollTop + (r0.top - box.top) - box.height / 3, behavior: reducedRef.current ? "auto" : "smooth" });
await sleep(reducedRef.current ? 0 : 320);
}
}
const fr = frame.getBoundingClientRect();
const r = el.getBoundingClientRect();
setTarget(id);
if (r.width > 0) {
const x = r.left - fr.left + Math.min(r.width / 2, 48);
setCursor((c) => ({ ...c, visible: true, x, y: r.top - fr.top + Math.min(r.height / 2, 22), flip: x > fr.width - 90 }));
}
await sleep(reducedRef.current ? 30 : 560 / speedRef.current);
return true;
}, []);
const perform = React.useCallback(
async (a: AgentAction, alive: () => boolean) => {
if (a.type !== "click" && a.type !== "type") return true;
const found = await moveTo(a.target);
if (!found || !alive()) return found;
const fast = reducedRef.current;
if (a.type === "click") {
setCursor((c) => ({ ...c, click: c.click + 1 }));
await sleep(fast ? 0 : 170 / speedRef.current);
applyStore(storeClick(storeRef.current, a.target, ctxRef.current));
} else {
setCursor((c) => ({ ...c, typing: true }));
applyStore(storeField(storeRef.current, a.target, ""));
for (let i = 1; i <= a.text.length; i++) {
if (!alive()) break;
applyStore(storeField(storeRef.current, a.target, a.text.slice(0, i)));
if (!fast) await sleep(55 / speedRef.current);
}
setCursor((c) => ({ ...c, typing: false }));
}
setTarget(null);
await frames(2);
return true;
},
[applyStore, moveTo],
);
const finish = React.useCallback(
(s: RunStatus, message: string) => {
setStatusBoth(s);
const page = storeRef.current.page;
const order = page.kind === "confirmation" ? page.order : null;
setResult({ message, order, seconds: (performance.now() - startedRef.current) / 1000 });
setCursor((c) => ({ ...c, visible: false, typing: false }));
setTarget(null);
setApproval(null);
setAnnounce(`${STATUS_LABEL[s]}. ${message}`);
if (order) callbacks.current.onOrder?.(order);
},
[setStatusBoth],
);
const loop = React.useCallback(
async (runId: number) => {
const alive = () => runRef.current === runId;
while (alive() && statusRef.current === "running") {
const obs = observe(storeRef.current, ctxRef.current, mandateRef.current, historyRef.current);
let step: AgentStep;
try {
step = await agentRef.current!(obs);
} catch (e) {
if (!alive()) return;
finish("failed", `The agent errored: ${e instanceof Error ? e.message : String(e)}`);
return;
}
if (!alive()) return;
historyRef.current = [...historyRef.current, step];
callbacks.current.onStep?.(step, obs);
const id = `s${runId}-${historyRef.current.length}`;
const label = "target" in step.action ? labelFor(obs, step.action.target) : "";
setLog((l) => [...l, { id, step, thought: "", state: "thinking", label }]);
if (!reducedRef.current) {
const parts = step.thought.split(/(?<= )/);
for (let i = 1; i <= parts.length; i++) {
await sleep(20 / speedRef.current);
if (!alive()) return;
patchLog(id, { thought: parts.slice(0, i).join("") });
}
}
patchLog(id, { thought: step.thought, state: "acting" });
const ok = await perform(step.action, alive);
if (!alive()) return;
patchLog(id, { state: ok ? "done" : "error" });
if (!ok) {
finish("failed", `Couldn't find “${label}” on the page.`);
return;
}
const a = step.action;
if (a.type === "await-approval") {
setApproval(a.amount);
setStatusBoth("approval");
setCursor((c) => ({ ...c, visible: false }));
setAnnounce(`Approval needed to pay ${money(a.amount)}`);
return;
}
if (a.type === "finish") return finish("done", a.summary);
if (a.type === "fail") return finish("blocked", a.reason);
if (historyRef.current.length >= 90) return finish("failed", "Stopped after 90 steps without finishing.");
await sleep(reducedRef.current ? 40 : 280 / speedRef.current);
if (stepOnceRef.current) {
stepOnceRef.current = false;
if (statusRef.current === "running") setStatusBoth("paused");
return;
}
}
},
[finish, money, perform, setStatusBoth],
);
/* -------------------------------- controls -------------------------------- */
const start = React.useCallback(() => {
if (!mandateRef.current.task.trim()) return;
const runId = ++runRef.current;
historyRef.current = [];
agentRef.current = agent ?? createLocalAgent({ products, money });
startedRef.current = performance.now();
stepOnceRef.current = false;
setLog([]);
setResult(null);
setApproval(null);
applyStore(INITIAL_STORE);
storeScrollRef.current?.scrollTo({ top: 0 });
const fr = frameRef.current?.getBoundingClientRect();
setCursor((c) => ({ ...c, visible: true, x: fr ? fr.width / 2 : 200, y: fr ? fr.height / 2 : 200 }));
setStatusBoth("running");
setTab("store");
setAnnounce("Agent started");
void loop(runId);
}, [agent, applyStore, loop, money, products, setStatusBoth]);
const pause = () => {
if (statusRef.current === "running") setStatusBoth("paused");
};
const resume = () => {
const s = statusRef.current;
if (s !== "paused" && s !== "takeover") return;
setStatusBoth("running");
setCursor((c) => ({ ...c, visible: true }));
void loop(runRef.current);
};
const stepOnce = () => {
if (statusRef.current !== "paused" && statusRef.current !== "takeover") return;
stepOnceRef.current = true;
resume();
};
const takeover = () => {
const s = statusRef.current;
if (s === "running" || s === "paused") {
setStatusBoth("takeover");
setCursor((c) => ({ ...c, visible: false }));
setTarget(null);
setTab("store");
setAnnounce("You're driving. The agent is paused.");
}
};
const stop = () => {
const s = statusRef.current;
if (s === "running" || s === "paused" || s === "takeover" || s === "approval") {
runRef.current++;
finish("stopped", "You stopped the run. The cart is kept as it was.");
}
};
const approve = async () => {
if (statusRef.current !== "approval") return;
const amount = approval ?? 0;
callbacks.current.onApprove?.(amount);
setApproval(null);
setStatusBoth("running");
const runId = runRef.current;
const alive = () => runRef.current === runId;
const id = `s${runId}-ok`;
const step: AgentStep = { thought: `You approved ${money(amount)}. Placing the order.`, action: { type: "click", target: "place-order" } };
setLog((l) => [...l, { id, step, thought: step.thought, state: "acting", label: "Place order" }]);
setCursor((c) => ({ ...c, visible: true }));
await perform(step.action, alive);
if (!alive()) return;
patchLog(id, { state: "done" });
historyRef.current = [...historyRef.current, step];
void loop(runId);
};
const decline = () => {
if (statusRef.current !== "approval") return;
callbacks.current.onDecline?.();
runRef.current++;
finish("declined", "You declined the payment. Nothing was charged and the cart is kept.");
};
const newTask = () => {
runRef.current++;
applyStore(INITIAL_STORE);
setLog([]);
setResult(null);
setStatusBoth("idle");
setTab("agent");
};
React.useEffect(() => () => void runRef.current++, []);
const autoRef = React.useRef(autoRun);
React.useEffect(() => {
if (autoRef.current) {
autoRef.current = false;
const t = setTimeout(start, 400);
return () => clearTimeout(t);
}
}, [start]);
// Keyboard shortcuts
const keys = React.useRef({ pause, resume, stepOnce, takeover, stop });
React.useEffect(() => {
keys.current = { pause, resume, stepOnce, takeover, stop };
});
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t && (t.closest("input, textarea, select, [contenteditable='true']") || (e.key === " " && t.closest("button, [role=radio], [role=checkbox]")))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const s = statusRef.current;
const k = keys.current;
if (e.key === " " && (s === "running" || s === "paused" || s === "takeover")) {
e.preventDefault();
if (s === "running") k.pause();
else k.resume();
} else if (e.key === "." && (s === "paused" || s === "takeover")) k.stepOnce();
else if ((e.key === "t" || e.key === "T") && (s === "running" || s === "paused")) k.takeover();
else if ((e.key === "t" || e.key === "T") && s === "takeover") k.resume();
else if (e.key === "Escape" && s !== "approval") k.stop();
else if (e.key === "?") setHelp((h) => !h);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Keep the newest step (and the approval gate) in view.
const lastLen = log[log.length - 1]?.thought.length ?? 0;
React.useEffect(() => {
const el = panelScrollRef.current;
if (el && status !== "idle") el.scrollTop = el.scrollHeight;
}, [log.length, lastLen, status, result, tab]);
/* --------------------------------- derived -------------------------------- */
const t = totals(store.cart, byId);
const spent = store.page.kind === "confirmation" ? store.page.order.total : t.total;
const live = status === "running" || status === "paused" || status === "approval";
const userCanDrive = !live;
const url = urlOf(store, byId);
const current = [...log].reverse().find((e) => e.thought);
const deadlines = React.useMemo(() => [null, ...new Set(SLOTS.map((s) => s.date))], []);
const started = status !== "idle";
const storeClickUser = (id: string) => {
if (!userCanDrive) return;
applyStore(storeClick(storeRef.current, id, ctxRef.current));
};
const storeFieldUser = (id: string, v: string) => {
if (!userCanDrive) return;
applyStore(storeField(storeRef.current, id, v));
};
const statusPill = (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold",
status === "running" ? "bg-[var(--sa-agent)]/12 text-[var(--sa-agent)]" : status === "approval" ? "bg-[var(--sa-warn)]/15 text-[var(--sa-warn)]" : status === "done" ? "bg-[var(--sa-ok)]/12 text-[var(--sa-ok)]" : status === "blocked" || status === "failed" ? "bg-[var(--sa-bad)]/12 text-[var(--sa-bad)]" : "bg-muted text-muted-foreground",
)}
>
{status === "running" && <motion.span className="size-1.5 rounded-full bg-current" animate={reduced ? undefined : { opacity: [1, 0.25, 1] }} transition={{ duration: 1, repeat: Infinity }} />}
{STATUS_LABEL[status]}
</span>
);
const panelBody = (
<div className="space-y-3 p-3 sm:p-4">
{!started ? (
<SetupForm
presets={presets}
presetId={presetId}
mandate={mandate}
money={money}
deadlines={deadlines}
onPreset={(p) => {
setPresetId(p.id);
setMandate({ task: p.task, cap: p.cap, deadline: p.deadline, askBeforePay: p.askBeforePay });
}}
onChange={(m) => {
setPresetId(null);
setMandate(m);
}}
onRun={start}
/>
) : (
<>
<div className="sticky top-0 z-10 -mx-3 -mt-3 space-y-2 border-b bg-background/95 px-3 pb-3 pt-3 backdrop-blur sm:-mx-4 sm:-mt-4 sm:px-4 sm:pt-4">
<MandateCard mandate={mandate} total={spent} money={money} />
<Controls status={status} speed={speed} onPause={pause} onResume={resume} onStep={stepOnce} onSpeed={() => setSpeed((s) => (s === 1 ? 2 : 1))} onTakeover={takeover} onStop={stop} />
</div>
{status === "takeover" && (
<p className="rounded-xl border border-dashed border-[var(--sa-agent)]/40 bg-[var(--sa-agent)]/[0.05] px-3 py-2 text-[12px]">
<strong>You're driving.</strong> Change anything in the store, then hand back: the agent re-plans from what it finds.
</p>
)}
<StepLog entries={log} />
{status === "approval" && approval !== null && <ApprovalCard amount={approval} items={store.cart.length} money={money} onApprove={() => void approve()} onDecline={decline} />}
{result && <SummaryCard status={status} message={result.message} order={result.order} steps={log.length} seconds={result.seconds} money={money} onNew={newTask} onAgain={start} />}
</>
)}
</div>
);
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased lg:flex-row", ROOT_VARS, className)}>
{/* Agent panel */}
<aside aria-label="Agent" className={cn("flex min-h-0 flex-col border-b lg:w-[380px] lg:shrink-0 lg:border-b-0 lg:border-r", wide || tab === "agent" ? "flex-1 lg:flex-none" : "shrink-0")}>
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:px-4">
<AgentMark />
<div className="min-w-0">
<p className="text-[14px] font-semibold leading-tight">{agentName}</p>
<p className="truncate text-[11px] text-muted-foreground">Shopping agent · {storeName}</p>
</div>
<div className="ml-auto flex items-center gap-1.5">
{statusPill}
<div className="relative">
<button type="button" onClick={() => setHelp((h) => !h)} aria-expanded={help} aria-label="Keyboard shortcuts" className={cn("grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground", focusRing)}>
<HelpCircle className="size-4" aria-hidden />
</button>
<AnimatePresence>
{help && (
<motion.div initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }} role="dialog" aria-label="Keyboard shortcuts" className="absolute right-0 top-10 z-50 w-56 rounded-xl border bg-popover p-3 text-[12px] text-popover-foreground shadow-xl">
{[
["Space", "Pause / resume"],
[".", "Step once"],
["T", "Take over / hand back"],
["Esc", "Stop the run"],
["⌘↵", "Run from the task box"],
["Hold ↵", "Approve payment"],
].map(([k, v]) => (
<p key={k} className="flex items-center justify-between py-1">
<span className="text-muted-foreground">{v}</span>
<kbd className="rounded border bg-muted px-1.5 font-mono text-[10.5px]">{k}</kbd>
</p>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* Mobile tabs */}
<div role="tablist" aria-label="View" className="flex shrink-0 gap-1 border-b p-1.5 lg:hidden">
{(["agent", "store"] as const).map((k) => (
<button
key={k}
type="button"
role="tab"
aria-selected={tab === k}
onClick={() => setTab(k)}
className={cn("relative h-8 flex-1 rounded-lg text-[12.5px] font-medium", tab === k ? "text-foreground" : "text-muted-foreground", focusRing)}
>
{tab === k && <motion.span layoutId="sa-tab" className="absolute inset-0 rounded-lg bg-muted" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<span className="relative">{k === "agent" ? `Agent${log.length ? ` · ${log.length}` : ""}` : "Store"}</span>
</button>
))}
</div>
<div ref={panelScrollRef} className={cn("min-h-0 flex-1 overflow-y-auto", !wide && tab !== "agent" && "hidden")}>
{panelBody}
</div>
</aside>
{/* Browser */}
<section aria-label="Store preview" className={cn("relative min-h-0 min-w-0 flex-1 flex-col bg-muted/40 p-0 sm:p-3 lg:p-4", !wide && tab !== "store" ? "hidden" : "flex")}>
<div
ref={frameRef}
className={cn(
"relative flex min-h-0 flex-1 flex-col overflow-hidden border-y bg-background shadow-sm transition-shadow sm:rounded-2xl sm:border",
live && "ring-2 ring-[var(--sa-agent)]/45 shadow-[0_0_0_6px] shadow-[var(--sa-agent)]/10",
status === "takeover" && "ring-2 ring-[var(--sa-store)]/50",
)}
>
{/* Chrome */}
<div className="flex h-10 shrink-0 items-center gap-2 border-b bg-muted/50 px-3">
<span className="hidden gap-1.5 sm:flex" aria-hidden>
<span className="size-2.5 rounded-full bg-[#ff5f57]" />
<span className="size-2.5 rounded-full bg-[#febc2e]" />
<span className="size-2.5 rounded-full bg-[#28c840]" />
</span>
<span className="hidden items-center text-muted-foreground/60 sm:flex" aria-hidden>
<ChevronLeft className="size-4" />
<ChevronRight className="size-4" />
<RotateCw className="ml-1 size-3.5" />
</span>
<div className="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-lg bg-background px-2.5 text-[11.5px] text-muted-foreground">
<Lock className="size-3 shrink-0" aria-hidden />
<AnimatePresence mode="wait" initial={false}>
<motion.span key={url} initial={{ opacity: 0, y: 3 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -3 }} transition={{ duration: 0.15 }} className="truncate font-mono" aria-label={`Address ${url}`}>
{url}
</motion.span>
</AnimatePresence>
</div>
<AnimatePresence>
{(live || status === "takeover") && (
<motion.span initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className={cn("hidden shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[10.5px] font-semibold text-white sm:inline-flex", status === "takeover" ? "bg-[var(--sa-store)]" : "bg-[var(--sa-agent)]")}>
{status === "takeover" ? <Hand className="size-3" aria-hidden /> : <AgentDot />}
{status === "takeover" ? "You're driving" : `${agentName} is driving`}
</motion.span>
)}
</AnimatePresence>
</div>
<div className="relative min-h-0 flex-1" inert={!userCanDrive ? true : undefined}>
<Storefront state={store} ctx={ctx} money={money} storeName={storeName} onClick={storeClickUser} onField={storeFieldUser} target={target} scrollRef={storeScrollRef} />
</div>
<GhostCursor state={cursor} speed={speed} />
</div>
{/* Mobile agent strip */}
{!wide && started && (
<div className="shrink-0 border-t bg-background p-2.5 sm:mt-2 sm:rounded-2xl sm:border">
{status === "approval" && approval !== null ? (
<ApprovalCard amount={approval} items={store.cart.length} money={money} onApprove={() => void approve()} onDecline={decline} />
) : (
<div className="flex items-center gap-2">
<button type="button" onClick={() => setTab("agent")} className={cn("flex min-w-0 flex-1 items-center gap-2 rounded-xl px-1 text-left", focusRing)} aria-label="Show agent steps">
<AgentMark className="size-7" />
<span className="min-w-0">
<span className="block text-[10.5px] font-semibold uppercase tracking-wide text-muted-foreground">
{STATUS_LABEL[status]} · {money(spent)} / {money(mandate.cap)}
</span>
<span className="line-clamp-2 text-[12px] leading-snug">{result?.message ?? current?.thought ?? "Starting…"}</span>
</span>
</button>
{status === "running" && (
<button type="button" onClick={pause} aria-label="Pause" className={cn("grid size-11 shrink-0 place-items-center rounded-xl border", focusRing)}>
<Pause className="size-4" aria-hidden />
</button>
)}
{(status === "paused" || status === "takeover") && (
<button type="button" onClick={resume} aria-label={status === "takeover" ? "Hand back to agent" : "Resume"} className={cn("grid size-11 shrink-0 place-items-center rounded-xl bg-[var(--sa-agent)] text-white", focusRing)}>
<Play className="size-4 fill-current" aria-hidden />
</button>
)}
{(status === "running" || status === "paused") && (
<button type="button" onClick={takeover} aria-label="Take over" className={cn("grid size-11 shrink-0 place-items-center rounded-xl border", focusRing)}>
<Hand className="size-4" aria-hidden />
</button>
)}
</div>
)}
</div>
)}
</section>
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
</MotionConfig>
);
}
function AgentDot() {
const reduced = useReducedMotion() ?? false;
return <motion.span className="size-1.5 rounded-full bg-white" animate={reduced ? undefined : { opacity: [1, 0.3, 1] }} transition={{ duration: 1, repeat: Infinity }} />;
}