"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { Check, History, Search, ShieldCheck, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { CatalogTable, useRowViews, type Flash } from "./catalog-table";
import { CommandBar, type CommandBarHandle } from "./command-bar";
import { DEFAULT_GUARDRAILS, EXAMPLES, ROWS_SEED } from "./data";
import { applyOps, createLocalProposer, isAsyncIterable, makeFmt, revertOps } from "./engine";
import { HistoryPanel } from "./history-panel";
import { PlanCard, type Draft } from "./plan-card";
import type { Changeset, Commit, Guardrails, PlanEvent, ProposeRequest, ProposeResult, Row } from "./types";
import { CopilotMark, ROOT_VARS, focusRing, useDialog, useMedia } from "./ui";
export type { Row, Changeset, Guardrails, ProposeRequest, ProposeResult, PlanEvent, Commit };
export type ChangesetCopilotAppProps = {
/** Catalogue rows. Default: 40 seeded home & kitchen SKUs. */
rows?: Row[];
title?: string;
currency?: string;
locale?: string;
examples?: string[];
guardrails?: Guardrails;
/**
* Replace the local engine with a model. Return a changeset (Promise) or stream `{ step }` lines
* followed by `{ changeset }`. Ops are plain JSON: `{ rowId, field, from, to, reason }`.
*/
propose?: (req: ProposeRequest) => ProposeResult;
onApply?: (changeset: Changeset, rows: Row[]) => void;
onRevert?: (commit: Commit, rows: Row[]) => void;
className?: string;
};
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function ChangesetCopilotApp({
rows: rowsProp = ROWS_SEED,
title = "Catalog Copilot",
currency = "PLN",
locale = "pl-PL",
examples = EXAMPLES,
guardrails: guardProp = DEFAULT_GUARDRAILS,
propose,
onApply,
onRevert,
className,
}: ChangesetCopilotAppProps) {
const reduced = useReducedMotion() ?? false;
const fmt = React.useMemo(() => makeFmt(currency, locale), [currency, locale]);
const local = React.useMemo(() => createLocalProposer(fmt), [fmt]);
const xl = useMedia("(min-width: 1280px)");
const md = useMedia("(min-width: 768px)");
const [rows, setRows] = React.useState<Row[]>(rowsProp);
const [guard, setGuard] = React.useState<Guardrails>(guardProp);
const [draft, setDraft] = React.useState<Draft | null>(null);
const [rejected, setRejected] = React.useState<Set<string>>(new Set());
const [commits, setCommits] = React.useState<Commit[]>([]);
const [flash, setFlash] = React.useState<Flash | null>(null);
const [query, setQuery] = React.useState("");
const [changedOnly, setChangedOnly] = React.useState(false);
const [historyOpen, setHistoryOpen] = React.useState(false);
const [guardOpen, setGuardOpen] = React.useState(false);
const [announce, setAnnounce] = React.useState("");
const cmdRef = React.useRef<CommandBarHandle>(null);
const abortRef = React.useRef<AbortController | null>(null);
const runRef = React.useRef(0);
const tableRef = React.useRef<HTMLDivElement>(null);
const histBtn = React.useRef<HTMLButtonElement>(null);
const sheetRef = React.useRef<HTMLDivElement>(null);
const guardBtn = React.useRef<HTMLButtonElement>(null);
const guardRef = React.useRef<HTMLDivElement>(null);
const seqRef = React.useRef(0);
useDialog(historyOpen && !xl, sheetRef, () => setHistoryOpen(false), histBtn);
const cs = draft?.status === "ready" ? draft.changeset ?? null : null;
const views = useRowViews(rows, cs, query, changedOnly);
const acceptedOps = React.useMemo(() => (cs ? cs.ops.filter((o) => !rejected.has(o.rowId)) : []), [cs, rejected]);
const acceptedRows = new Set(acceptedOps.map((o) => o.rowId)).size;
/* -------------------------------- propose -------------------------------- */
const run = React.useCallback(
async (command: string) => {
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
const id = ++runRef.current;
const alive = () => runRef.current === id;
setDraft({ command, steps: [], status: "planning" });
setRejected(new Set());
setAnnounce("Planning the change");
try {
const req: ProposeRequest = { command, rows, guardrails: guard, signal: ac.signal };
const out = (propose ?? local)(req);
const done = (c: Changeset) => {
if (!alive()) return;
setDraft((d) => (d ? { ...d, status: "ready", changeset: c, steps: d.steps.length ? d.steps : c.plan } : d));
setChangedOnly(!window.matchMedia("(min-width: 768px)").matches);
const cells = c.ops.length;
setAnnounce(`Proposal ready: ${cells} cell change${cells === 1 ? "" : "s"} across ${new Set(c.ops.map((o) => o.rowId)).size} products, ${c.warnings.length} adjusted, ${c.skipped.length} skipped.`);
requestAnimationFrame(() => {
const first = tableRef.current?.querySelector<HTMLElement>("tr[tabindex='0'], li[tabindex='0']");
if (first && tableRef.current) {
const box = tableRef.current.getBoundingClientRect();
const r = first.getBoundingClientRect();
if (r.top < box.top || r.bottom > box.bottom) tableRef.current.scrollBy({ top: r.top - box.top - 60, behavior: reduced ? "auto" : "smooth" });
}
});
};
const clarify = (message: string) => alive() && setDraft((d) => (d ? { ...d, status: "clarify", message } : d));
if (isAsyncIterable<PlanEvent>(out)) {
for await (const ev of out) {
if (!alive()) return;
if ("step" in ev) setDraft((d) => (d ? { ...d, steps: [...d.steps, ev.step] } : d));
else if ("changeset" in ev) done(ev.changeset);
else clarify(ev.clarify);
}
} else {
const res = await out;
if (!alive()) return;
if ("clarify" in res) clarify(res.clarify);
else {
for (const step of res.plan) {
setDraft((d) => (d ? { ...d, steps: [...d.steps, step] } : d));
await sleep(reduced ? 0 : 260);
if (!alive()) return;
}
done(res);
}
}
} catch (e) {
if (!alive() || (e instanceof DOMException && e.name === "AbortError")) return;
setDraft((d) => (d ? { ...d, status: "error", message: e instanceof Error ? e.message : "Something went wrong." } : d));
}
},
[guard, local, propose, reduced, rows],
);
const discard = React.useCallback(() => {
abortRef.current?.abort();
runRef.current++;
setDraft(null);
setRejected(new Set());
setAnnounce("Proposal discarded");
}, []);
const now = () => new Date().toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
const flashRows = (ids: string[]) => {
const map = new Map(ids.map((id, i) => [id, i]));
const key = ++seqRef.current;
setFlash({ ids: map, key });
setTimeout(() => setFlash((f) => (f && f.key === key ? null : f)), 1400);
};
const apply = React.useCallback(() => {
if (!cs || !acceptedOps.length) return;
const next = applyOps(rows, acceptedOps);
const n = commits.length + 1;
const commit: Commit = { id: `c${n}-${cs.id}`, n, title: cs.command, ops: acceptedOps, at: now() };
setRows(next);
setCommits((c) => [commit, ...c]);
setDraft(null);
setRejected(new Set());
flashRows([...new Set(acceptedOps.map((o) => o.rowId))]);
setAnnounce(`Applied ${acceptedOps.length} changes as commit ${n}`);
onApply?.({ ...cs, ops: acceptedOps }, next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cs, acceptedOps, rows, commits.length, onApply]);
const revert = React.useCallback(
(c: Commit) => {
const { ops, conflicts } = revertOps(rows, c.ops);
if (!ops.length) {
setAnnounce("Nothing to revert: those cells changed since.");
return;
}
const next = applyOps(rows, ops);
const n = commits.length + 1;
const commit: Commit = { id: `c${n}-r`, n, title: `Revert #${c.n}: ${c.title}`, ops, at: now(), revertOf: c.id };
setRows(next);
setCommits((list) => [commit, ...list.map((x) => (x.id === c.id ? { ...x, reverted: true } : x))]);
flashRows([...new Set(ops.map((o) => o.rowId))]);
setAnnounce(`Reverted commit ${c.n}${conflicts.length ? `; ${conflicts.length} cell${conflicts.length === 1 ? " was" : "s were"} changed later and kept` : ""}`);
onRevert?.(c, next);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[rows, commits.length, onRevert],
);
const toggle = (id: string) =>
setRejected((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
const acceptAll = () => setRejected(new Set());
const rejectAll = () => cs && setRejected(new Set(cs.ops.map((o) => o.rowId)));
/* ------------------------------- shortcuts ------------------------------- */
const live = React.useRef({ apply, discard, acceptAll, rejectAll, revert, commits, cs, draft });
React.useEffect(() => {
live.current = { apply, discard, acceptAll, rejectAll, revert, commits, cs, draft };
});
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
const typing = !!t?.closest("input:not([type=checkbox]), textarea, select, [contenteditable='true']");
const L = live.current;
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key === "Enter" && L.cs) {
e.preventDefault();
L.apply();
return;
}
if (mod && (e.key === "k" || e.key === "K")) {
e.preventDefault();
cmdRef.current?.focus();
return;
}
if (typing) return;
if (e.key === "/") {
e.preventDefault();
cmdRef.current?.focus();
} else if (mod && (e.key === "z" || e.key === "Z")) {
const last = L.commits.find((c) => !c.reverted);
if (last) {
e.preventDefault();
L.revert(last);
}
} else if (!mod && L.cs && (e.key === "a" || e.key === "A")) L.acceptAll();
else if (!mod && L.cs && (e.key === "r" || e.key === "R")) L.rejectAll();
else if (e.key === "Escape" && L.draft && !document.querySelector("[data-cc-sheet]")) L.discard();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
React.useEffect(() => {
if (!guardOpen) return;
const onDown = (e: PointerEvent) => {
if (!guardRef.current?.contains(e.target as Node) && !guardBtn.current?.contains(e.target as Node)) setGuardOpen(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [guardOpen]);
React.useEffect(() => () => abortRef.current?.abort(), []);
const lastCommit = commits.find((c) => !c.reverted);
const busy = draft?.status === "planning";
const guardCount = 1 + 1 + (guard.lockPromo ? 1 : 0) + (guard.rounding !== "none" ? 1 : 0);
const historyProps = { commits, draft: cs ? { command: cs.command, cells: acceptedOps.length } : null, onRevert: revert };
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", ROOT_VARS, className)}>
<div className="flex min-w-0 flex-1 flex-col" inert={historyOpen && !xl ? true : undefined}>
{/* Header */}
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2.5 sm:px-4 md:flex-nowrap">
<div className="flex min-w-0 items-center gap-2 md:w-56 md:shrink-0">
<CopilotMark />
<div className="min-w-0">
<p className="truncate text-[14px] font-semibold leading-tight">{title}</p>
<p className="truncate text-[11px] text-muted-foreground">{rows.length} products · diff-reviewed edits</p>
</div>
</div>
<div className="order-last w-full md:order-none md:w-auto md:flex-1">
<CommandBar ref={cmdRef} examples={examples} busy={busy} onSubmit={(c) => void run(c)} onStop={discard} onEscape={() => draft && discard()} />
</div>
<div className="ml-auto flex items-center gap-1">
<div className="relative">
<button
ref={guardBtn}
type="button"
onClick={() => setGuardOpen((o) => !o)}
aria-expanded={guardOpen}
aria-haspopup="dialog"
className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl px-2.5 text-[12.5px] font-medium hover:bg-accent", guardOpen && "bg-accent", focusRing)}
>
<ShieldCheck className="size-4 text-[var(--cc-up)]" aria-hidden />
<span className="hidden lg:inline">Guardrails</span>
<span className="rounded-full bg-muted px-1.5 text-[10.5px] tabular-nums">{guardCount}</span>
</button>
<AnimatePresence>
{guardOpen && (
<motion.div
ref={guardRef}
role="dialog"
aria-label="Guardrails"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
setGuardOpen(false);
guardBtn.current?.focus();
}
}}
className="absolute right-0 top-11 z-50 w-72 space-y-3 rounded-2xl border bg-popover p-4 text-[12.5px] text-popover-foreground shadow-xl"
>
<p className="text-[11.5px] text-muted-foreground">Every proposal is checked against these before you see it.</p>
<label className="flex items-center justify-between gap-3">
Margin floor
<span className="inline-flex items-center gap-1">
<input type="number" min={0} max={90} value={guard.marginFloor} onChange={(e) => setGuard({ ...guard, marginFloor: Math.max(0, Math.min(90, Number(e.target.value) || 0)) })} className="h-8 w-16 rounded-lg border bg-background px-2 text-right tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring" />%
</span>
</label>
<label className="flex items-center justify-between gap-3">
Max price change per run
<span className="inline-flex items-center gap-1">
<input type="number" min={1} max={100} value={guard.maxChange} onChange={(e) => setGuard({ ...guard, maxChange: Math.max(1, Math.min(100, Number(e.target.value) || 1)) })} className="h-8 w-16 rounded-lg border bg-background px-2 text-right tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring" />%
</span>
</label>
<label className="flex cursor-pointer items-center justify-between gap-3">
Lock prices of promo items
<input type="checkbox" checked={guard.lockPromo} onChange={(e) => setGuard({ ...guard, lockPromo: e.target.checked })} className="size-4 accent-[var(--cc-accent)]" />
</label>
<fieldset>
<legend className="mb-1.5">Default rounding</legend>
<div className="grid grid-cols-4 gap-1 rounded-xl bg-muted p-1">
{(["none", ".99", ".49", "whole"] as const).map((r) => (
<label key={r} className={cn("cursor-pointer rounded-lg py-1 text-center text-[12px] capitalize has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring", guard.rounding === r ? "bg-background font-semibold shadow-sm" : "text-muted-foreground")}>
<input type="radio" name="cc-round" value={r} checked={guard.rounding === r} onChange={() => setGuard({ ...guard, rounding: r })} className="sr-only" />
{r}
</label>
))}
</div>
</fieldset>
</motion.div>
)}
</AnimatePresence>
</div>
{lastCommit && (
<button type="button" onClick={() => revert(lastCommit)} aria-label={`Undo commit ${lastCommit.n}`} aria-keyshortcuts="Control+Z Meta+Z" className={cn("hidden h-9 items-center gap-1.5 rounded-xl px-2.5 text-[12.5px] font-medium hover:bg-accent sm:inline-flex", focusRing)}>
<Undo2 className="size-4" aria-hidden /> <span className="hidden lg:inline">Undo</span>
</button>
)}
{!xl && (
<button ref={histBtn} type="button" onClick={() => setHistoryOpen(true)} aria-label={`History, ${commits.length} commit${commits.length === 1 ? "" : "s"}`} className={cn("relative grid size-9 place-items-center rounded-xl hover:bg-accent", focusRing)}>
<History className="size-4.5" aria-hidden />
{commits.length > 0 && <span className="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-[var(--cc-accent)] px-1 text-[10px] font-semibold text-white">{commits.length}</span>}
</button>
)}
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2 sm:p-3">
<AnimatePresence>
{draft && (
<PlanCard
key={`plan-${draft.command}`}
draft={draft}
fmt={fmt}
acceptedRows={acceptedRows}
acceptedCells={acceptedOps.length}
totalRows={rows.length}
examples={examples}
onAcceptAll={acceptAll}
onRejectAll={rejectAll}
onDiscard={discard}
onApply={apply}
onExample={(e) => void run(e)}
onRetry={() => draft && void run(draft.command)}
compact={!md}
/>
)}
</AnimatePresence>
<AnimatePresence initial={false}>
{!draft && !commits.length && (
<motion.div
key="hint"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="shrink-0 overflow-hidden"
>
<div className="flex items-center gap-2 overflow-x-auto rounded-2xl border border-dashed bg-[var(--cc-accent)]/[0.04] px-3 py-2 [scrollbar-width:none]">
<span className="shrink-0 text-[12px] font-medium text-muted-foreground">Try</span>
{examples.slice(0, 3).map((e) => (
<button key={e} type="button" onClick={() => void run(e)} className={cn("h-7 shrink-0 rounded-full border bg-background px-3 text-[12px] transition hover:border-[var(--cc-accent)]/50 hover:text-[var(--cc-accent)]", focusRing)}>
{e}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<section aria-label="Catalogue" className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border bg-card">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="relative min-w-32 flex-1 sm:max-w-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
<label htmlFor="cc-filter" className="sr-only">
Filter products
</label>
<input id="cc-filter" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter products" className="h-8 w-full rounded-lg border bg-background pl-8 pr-2 text-[12.5px] outline-none focus-visible:ring-2 focus-visible:ring-ring" />
</div>
{cs && (
<button type="button" role="switch" aria-checked={changedOnly} onClick={() => setChangedOnly((v) => !v)} className={cn("inline-flex h-8 items-center gap-2 rounded-lg px-2 text-[12px] font-medium", focusRing)}>
<span className={cn("relative h-5 w-8 rounded-full transition-colors", changedOnly ? "bg-[var(--cc-accent)]" : "bg-muted-foreground/30")}>
<motion.span layout className={cn("absolute top-0.5 size-4 rounded-full bg-white shadow", changedOnly ? "right-0.5" : "left-0.5")} transition={{ type: "spring", stiffness: 600, damping: 34 }} />
</span>
Changed only
</button>
)}
<span className={cn("ml-auto text-[11.5px] text-muted-foreground tabular-nums", cs && "hidden sm:inline")}>
{cs ? (
<>
<strong className="text-[var(--cc-accent)]">{acceptedOps.length}</strong> of {cs.ops.length} changes accepted
<span className="hidden sm:inline"> · Space toggles a row · A / R all</span>
</>
) : (
`${views.length} of ${rows.length} products`
)}
</span>
</div>
<div ref={tableRef} className="min-h-0 flex-1 overflow-auto">
<CatalogTable views={views} fmt={fmt} hasProposal={!!cs} rejected={rejected} onToggle={toggle} flash={flash} marginFloor={guard.marginFloor} />
</div>
</section>
</div>
{/* Mobile action bar */}
<AnimatePresence>
{cs && (
<motion.div initial={{ y: 60 }} animate={{ y: 0 }} exit={{ y: 60 }} className="flex shrink-0 items-center gap-2 border-t bg-background p-2.5 md:hidden">
<button type="button" onClick={discard} className={cn("h-11 rounded-xl border px-4 text-[13px] font-medium", focusRing)}>
Discard
</button>
<button type="button" onClick={apply} disabled={!acceptedOps.length} className={cn("inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-xl bg-[var(--cc-accent)] text-[13.5px] font-semibold text-white disabled:opacity-40", focusRing)}>
<Check className="size-4" aria-hidden /> Apply {acceptedOps.length}
</button>
</motion.div>
)}
</AnimatePresence>
</div>
{/* History rail */}
{xl && (
<aside aria-label="History" className="w-72 shrink-0 border-l bg-muted/20">
<HistoryPanel {...historyProps} />
</aside>
)}
<AnimatePresence>
{historyOpen && !xl && (
<>
<motion.div key="scrim" className="absolute inset-0 z-40 bg-black/40" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setHistoryOpen(false)} aria-hidden />
<motion.div
key="sheet"
ref={sheetRef}
data-cc-sheet
role="dialog"
aria-modal="true"
aria-label="History"
className="absolute inset-y-0 right-0 z-50 w-[86%] max-w-80 border-l bg-background shadow-2xl"
initial={reduced ? { opacity: 0 } : { x: "100%" }}
animate={reduced ? { opacity: 1 } : { x: 0 }}
exit={reduced ? { opacity: 0 } : { x: "100%" }}
transition={{ type: "spring", stiffness: 380, damping: 38 }}
>
<HistoryPanel {...historyProps} onClose={() => setHistoryOpen(false)} />
</motion.div>
</>
)}
</AnimatePresence>
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
</MotionConfig>
);
}