Fazekit

Code

"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&apos;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 }} />;
}

More in AI

View all →