diff --git a/.chatui/approval-card.tsx b/.chatui/approval-card.tsx new file mode 100644 index 000000000..8b351cfa2 --- /dev/null +++ b/.chatui/approval-card.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; +import { Button } from "@/components/atoms/Button"; +import GlideMenu from "@/components/primitives/GlideMenu"; + +/* ───────────────────────────────────────────────────────── + * APPROVAL CARD (human-in-the-loop) + * One question at a time. The stack slides vertically as you + * move between questions (the card's height animates to fit), + * the step counter rolls like an odometer, and the footer uses + * pill actions — a quiet Skip and a dark Continue with a ⏎. + * Single-choice answers auto-advance; multi-select waits. + * ───────────────────────────────────────────────────────── */ + +const QUESTIONS = [ + { + q: "How many flavors should we launch?", + type: "radio" as const, + options: ["Three (core line)", "Five (full case)", "Just one hero"], + }, + { + q: "Which mix-ins should we stock?", + type: "check" as const, + options: ["Chocolate chips", "Waffle bits", "Sprinkles"], + }, + { + q: "Which market do we enter first?", + type: "radio" as const, + options: ["Food trucks", "Grocery freezers", "Scoop shops"], + }, +]; + +const ROLL_MS = 400; +const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)"; + +/* odometer digits — each character that changes rolls up (or down) */ +function RollingDigits({ value }: { value: string }) { + const prevRef = useRef(value); + const [oldVal, setOldVal] = useState(value); + const [newVal, setNewVal] = useState(value); + const [rolling, setRolling] = useState(false); + const [shifted, setShifted] = useState(false); + const [dir, setDir] = useState<"up" | "down">("up"); + + useEffect(() => { + if (prevRef.current === value) return; + const from = prevRef.current; + prevRef.current = value; + const fromN = parseInt(from, 10); + const toN = parseInt(value, 10); + setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up"); + setOldVal(from); + setNewVal(value); + setRolling(true); + setShifted(false); + + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => setShifted(true)); + }); + const done = setTimeout(() => { + setRolling(false); + setOldVal(value); + setShifted(false); + }, ROLL_MS); + + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + clearTimeout(done); + }; + }, [value]); + + const chars = rolling ? newVal : oldVal; + + return ( + <> + {Array.from({ length: chars.length }, (_, i) => { + const o = oldVal[i] ?? ""; + const n = chars[i] ?? ""; + if (!rolling || o === n) { + return {n}; + } + const top = dir === "down" ? n : o; + const bottom = dir === "down" ? o : n; + const restY = dir === "down" ? "0" : "-1em"; + const startY = dir === "down" ? "-1em" : "0"; + return ( + + + {top} + {bottom} + + + ); + })} + + ); +} + +function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) { + return ( + + {path} + + ); +} + +export default function ApprovalCard({ + onSubmitted, + resettable = true, +}: { + onSubmitted?: () => void; + resettable?: boolean; + variant?: string; +} = {}) { + const [qi, setQi] = useState(0); + const [answers, setAnswers] = useState>({}); + const [custom, setCustom] = useState>({}); + const [sent, setSent] = useState(false); + const [open, setOpen] = useState(true); + + const advanceTimer = useRef | null>(null); + const questionRefs = useRef<(HTMLDivElement | null)[]>([]); + const measured = useRef(false); + const [viewportH, setViewportH] = useState(undefined); + const [trackY, setTrackY] = useState(0); + const [animate, setAnimate] = useState(false); + // Until the first question is measured, render only the active one so the + // initial (and SSR) height is Q1's height — not all questions stacked, which + // would flash to full height and then shrink on mount. + const [ready, setReady] = useState(false); + + const last = qi === QUESTIONS.length - 1; + const selected = answers[qi] ?? []; + const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim()); + + const sync = (withAnim: boolean) => { + const item = questionRefs.current[qi]; + if (!item) return; + const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + setViewportH(item.offsetHeight); + setTrackY(item.offsetTop); + setAnimate(withAnim && !reduce); + }; + + useLayoutEffect(() => { + const withAnim = measured.current; + measured.current = true; + sync(withAnim); + setReady(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qi, answers, custom, open, sent]); + + useEffect(() => { + const id = requestAnimationFrame(() => sync(measured.current)); + return () => cancelAnimationFrame(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qi]); + + useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []); + + const goTo = (next: number) => { + if (advanceTimer.current) clearTimeout(advanceTimer.current); + setQi(Math.min(Math.max(next, 0), QUESTIONS.length - 1)); + }; + + const send = () => { + if (advanceTimer.current) clearTimeout(advanceTimer.current); + setSent(true); + onSubmitted?.(); + }; + + const advance = () => { + if (last) send(); + else goTo(qi + 1); + }; + + const toggle = (index: number) => { + const type = QUESTIONS[qi].type; + setAnswers((current) => { + const picked = current[qi] ?? []; + const next = type === "radio" + ? [index] + : picked.includes(index) + ? picked.filter((item) => item !== index) + : [...picked, index]; + return { ...current, [qi]: next }; + }); + if (type === "radio") { + setCustom((current) => ({ ...current, [qi]: "" })); + if (advanceTimer.current) clearTimeout(advanceTimer.current); + advanceTimer.current = setTimeout(() => { + if (last) send(); + else setQi((current) => Math.min(QUESTIONS.length - 1, current + 1)); + }, 480); + } + }; + + const reset = () => { + setQi(0); + setAnswers({}); + setCustom({}); + setSent(false); + setOpen(true); + measured.current = false; + }; + + if (!open) { + return ( + + ); + } + + if (sent) { + return ( +
+ + + + + Answers sent + + {resettable && ( + + )} +
+ ); + } + + return ( +
+
+ +
+ {/* the question itself is the heading */} +
+
+ {QUESTIONS.map((question, qIdx) => { + const active = qIdx === qi; + // Before the first measure, mount only the active question so the + // card opens at its real height instead of flashing to full height. + if (!ready && !active) return null; + const picked = answers[qIdx] ?? []; + const questionStyle: CSSProperties = { + opacity: active ? 1 : 0, + transition: animate ? `opacity ${SLIDE}` : undefined, + pointerEvents: active ? undefined : "none", + }; + return ( +
{ questionRefs.current[qIdx] = el; }} + aria-hidden={active ? undefined : true} + style={questionStyle} + > +
{question.q}
+ + {question.options.map((option, i) => { + const on = picked.includes(i); + return ( + + ); + })} + + +
+ ); + })} +
+
+
+ + {/* footer — step nav (rolling counter) + pill actions */} +
+
+ + + + + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/.chatui/code-block.tsx b/.chatui/code-block.tsx new file mode 100644 index 000000000..dc927f7d0 --- /dev/null +++ b/.chatui/code-block.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useCallback, useState, type ReactNode } from "react"; + +/* ───────────────────────────────────────────────────────── + * CODE BLOCK + * A light editor panel with two versions (switch in the card): + * · Code — a line-numbered listing + * · Diff — a unified diff: old/new gutters, a green/red accent + * bar and row tint, plus word-level add/del highlights. + * Both share syntax coloring, insets, and wrapping behavior. + * ───────────────────────────────────────────────────────── */ + +const FILE = "churn.ts"; + +const CODE_LINES = [ + "export async function churnBatch() {", + ' const flavor = await getFlavor("pistachio");', + " const base = await dairy.fetch({ flavor });", + ' await freezer.store(base, { temp: "-16C" });', + " if (!base.approved) return null;", + " return base.gallons;", + "}", +]; +const RAW = CODE_LINES.join("\n"); + +type Piece = { text: string; change?: "add" | "del" }; +type Row = { old: number | null; cur: number | null; type: "ctx" | "add" | "del"; pieces: Piece[] }; + +const DIFF: Row[] = [ + { old: 1, cur: 1, type: "ctx", pieces: [{ text: "export async function churnBatch() {" }] }, + { old: 2, cur: 2, type: "ctx", pieces: [{ text: ' const flavor = await getFlavor("pistachio");' }] }, + { old: 3, cur: 3, type: "ctx", pieces: [{ text: " const base = await dairy.fetch({ flavor });" }] }, + { old: 4, cur: null, type: "del", pieces: [{ text: " await freezer.store(base, { temp: " }, { text: '"-14C"', change: "del" }, { text: " });" }] }, + { old: null, cur: 4, type: "add", pieces: [{ text: " await freezer.store(base, { temp: " }, { text: '"-16C"', change: "add" }, { text: " });" }] }, + { old: null, cur: 5, type: "add", pieces: [{ text: " if (!base.approved) return null;" }] }, + { old: 5, cur: 6, type: "ctx", pieces: [{ text: " return base.gallons;" }] }, + { old: 6, cur: 7, type: "ctx", pieces: [{ text: "}" }] }, +]; + +const HATCH = "repeating-linear-gradient(45deg, var(--red) 0, var(--red) 1.5px, transparent 1.5px, transparent 3px)"; + +/* light syntax coloring — keywords/imports/conditionals, functions, strings & numbers */ +const KEYWORDS = new Set(["import", "from", "export", "default", "async", "function", "const", "let", "var", "await", "return", "if", "else", "for", "while", "new", "throw", "try", "catch", "null", "true", "false", "undefined"]); +const TOKEN = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|\b\d+(?:\.\d+)?\b|\b(?:import|from|export|default|async|function|const|let|var|await|return|if|else|for|while|new|throw|try|catch|null|true|false|undefined)\b|[A-Za-z_$][\w$]*(?=\s*\())/g; + +function highlight(text: string): ReactNode[] { + const nodes: ReactNode[] = []; + let last = 0; + let k = 0; + for (const m of text.matchAll(TOKEN)) { + const idx = m.index ?? 0; + const t = m[0]; + if (idx > last) nodes.push({text.slice(last, idx)}); + let color: string; + let weight: number | undefined; + if (/^["'`]/.test(t) || /^\d/.test(t)) color = "var(--orange)"; // string / number + else if (KEYWORDS.has(t)) color = "var(--accent-ink)"; // keyword / import / conditional + else { color = "var(--ink)"; weight = 500; } // function call + nodes.push({t}); + last = idx + t.length; + } + if (last < text.length) nodes.push({text.slice(last)}); + return nodes; +} + +function Pieces({ pieces }: { pieces: Piece[] }) { + return ( + <> + {pieces.map((p, i) => { + if (p.change) { + const add = p.change === "add"; + return ( + + {highlight(p.text)} + + ); + } + return {highlight(p.text)}; + })} + + ); +} + +function FileIcon() { + return ( + + + + ); +} + +export default function CodeBlock({ variant = "Code" }: { variant?: string }) { + const [copied, setCopied] = useState(false); + const isDiff = variant === "Diff"; + + const copy = useCallback(() => { + navigator.clipboard.writeText(RAW).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }, []); + + const added = DIFF.filter((r) => r.type === "add").length; + const removed = DIFF.filter((r) => r.type === "del").length; + + return ( +
+ {/* header — file · (diff stat | copy) */} +
+ + + {FILE} + + + {isDiff ? ( + + +{added} + -{removed} + + ) : ( + + )} +
+ + {/* body — equal 12px inset on top / left / right; lines wrap */} +
+ {isDiff ? ( +
+ + {DIFF.map((r, i) => { + const add = r.type === "add"; + const del = r.type === "del"; + // one gutter column: removals keep the old number, additions/context show the new one + const num = del ? r.old : r.cur; + return ( +
+ {(add || del) && ( + + )} + {num ?? ""} + + + +
+ ); + })} +
+ ) : ( +
+ + {CODE_LINES.map((line, i) => ( +
+ {i + 1} + {highlight(line)} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/.chatui/context-card.tsx b/.chatui/context-card.tsx new file mode 100644 index 000000000..a6debef43 --- /dev/null +++ b/.chatui/context-card.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/* ───────────────────────────────────────────────────────── + * CONTEXT CARDS + * Retrieved chunks enter once, then remain available. + * ───────────────────────────────────────────────────────── */ + +const CHUNKS = [ + { + title: "Vendor onboarding rule", + chars: "290 characters", + body: "Cold-chain certification must be verified before a new dairy can be added to the reorder workflow.", + source: "Dairy Onboarding SOP.pdf", + badge: "PDF", + tone: "bg-red", + }, + { + title: "Seasonal demand row", + chars: "1,250 characters", + body: "Q4 velocity table: pistachio +18%, vanilla +6%, rocky road -11%; retire flavors below 40 scoops weekly.", + source: "Sales Velocity Export.csv", + badge: "CSV", + tone: "bg-green", + }, +]; + +export default function ContextCards() { + const [chipsShown, setChipsShown] = useState(false); + + useEffect(() => { + const chips = setTimeout(() => setChipsShown(true), 700); + return () => clearTimeout(chips); + }, []); + + return ( +
+
+ All chunks + + 32 + +
+ + {CHUNKS.map((chunk, i) => ( +
+
+ + + {chunk.title} + + {chunk.chars} +
+

+ {chunk.body} +

+
+ + + {chunk.badge} + + {chunk.source} + + +
+
+ ))} +
+ ); +} diff --git a/.chatui/diff-table.tsx b/.chatui/diff-table.tsx new file mode 100644 index 000000000..0ab76e885 --- /dev/null +++ b/.chatui/diff-table.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/atoms/Button"; + +/* ───────────────────────────────────────────────────────── + * DIFF TABLE + * The proposed edit plays once and rests on the completed + * diff. Each changed row is the control: click it to include + * or exclude that specific addition/removal before applying. + * ───────────────────────────────────────────────────────── */ + +function useStage(steps: number[]) { + const [stage, setStage] = useState(0); + useEffect(() => { + if (stage >= steps.length) return; + const t = setTimeout(() => setStage((s) => s + 1), steps[stage]); + return () => clearTimeout(t); + }, [stage, steps]); + return stage; +} + +const STAGE_DELAYS = [180, 260]; + +const ROWS = [ + { key: "rocky", id: "Rocky Road", dept: "Classic", email: "aurora-scoops", removed: true }, + { key: "bubblegum", id: "Bubblegum", dept: "Retro", email: "kumo-creamery", removed: true }, + { key: "mint", id: "Mint Chip", dept: "Classic", email: "maple-orbit", removed: false }, +]; + +const DOT: Record = { + Classic: "bg-accent", + Retro: "bg-ink-3", + Seasonal: "bg-orange", +}; + +function IncludedMark({ included, tone }: { included: boolean; tone: "red" | "green" }) { + return ( + + {included ? ( + + ) : null} + + ); +} + +export default function DiffTable() { + const stage = useStage(STAGE_DELAYS); + // 0 plain · 1 removals · 2 completed diff + const tinted = stage >= 1; + const settled = stage >= 2; + const [accepted, setAccepted] = useState(false); + const [edits, setEdits] = useState>({ rocky: true, bubblegum: true, pistachio: true }); + + const removals = ["rocky", "bubblegum"].filter((key) => edits[key]).length; + const additions = edits.pistachio ? 1 : 0; + const showAdded = settled; + + const toggleEdit = (key: string) => setEdits((current) => ({ ...current, [key]: !current[key] })); + + return ( +
+
+
+ Proposed menu cleanup + {settled && !accepted && Click changed rows to toggle} +
+ + + + + + + + + + {["Flavor", "Category", "Supplier"].map((h) => ( + + ))} + + + + {ROWS.map((row) => { + const out = row.removed && tinted && edits[row.key]; + const interactive = row.removed && settled && !accepted; + return ( + toggleEdit(row.key) : undefined} + onKeyDown={interactive ? (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleEdit(row.key); + } + } : undefined} + className={`border-b border-line transition-[background-color,filter,opacity] duration-150 last:border-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${interactive ? "cursor-pointer hover:brightness-[0.985]" : "" + }`} + style={{ background: out ? "var(--red-tint)" : undefined }} + > + + + + + ); + })} + {/* added row */} + + + + +
+ {h} +
+ {row.id} + + + + {row.dept} + + + + {row.email} + {row.removed && settled && } + +
+
+
+
toggleEdit("pistachio")} + onKeyDown={accepted ? undefined : (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleEdit("pistachio"); + } + }} + className={`grid grid-cols-[34%_30%_36%] items-center border-t border-line transition-[background-color,filter,opacity] duration-150 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${accepted ? "" : "cursor-pointer hover:brightness-[0.985]" + }`} + style={{ background: edits.pistachio ? "var(--green-tint)" : undefined }} + > + + Pistachio + + + + + Seasonal + + + + + maple-orbit + + + +
+
+
+
+ + {/* footer — the summary follows the row-level selection */} + {settled && ( +
+ {accepted ? ( + + + + + {removals + additions} {removals + additions === 1 ? "edit" : "edits"} applied + + ) : ( + <> + + {removals} {removals === 1 ? "removal" : "removals"} · {additions} {additions === 1 ? "addition" : "additions"} + + + + + + )} +
+ )} +
+
+ ); +} diff --git a/.chatui/filter-table.tsx b/.chatui/filter-table.tsx new file mode 100644 index 000000000..b12de8455 --- /dev/null +++ b/.chatui/filter-table.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState } from "react"; + +/* ───────────────────────────────────────────────────────── + * FILTER TABLE + * Status chips directly filter the task table. + * ───────────────────────────────────────────────────────── */ + +type Status = "todo" | "progress" | "done"; + +const FILTERS: { key: "all" | Status; label: string; dot?: string; count: number }[] = [ + { key: "all", label: "All", count: 5 }, + { key: "todo", label: "To do", dot: "#f09a2f", count: 2 }, + { key: "progress", label: "In Progress", dot: "#16a6c7", count: 2 }, + { key: "done", label: "Completed", dot: "#25a878", count: 1 }, +]; + +const ROWS: { task: string; date: string; status: Status; owner: string }[] = [ + { task: "Restock mango sorbet", date: "Dec 03", status: "todo", owner: "Mango Moon Gelato" }, + { task: "Churn black sesame", date: "Sep 22", status: "progress", owner: "Kumo Creamery" }, + { task: "Print summer menu", date: "Jan 02", status: "todo", owner: "Coral Coast Sorbet" }, + { task: "Taste-test batch 42", date: "Nov 08", status: "progress", owner: "Maple Orbit" }, + { task: "Order waffle cones", date: "Apr 14", status: "done", owner: "Aurora Scoops" }, +]; + +const PILLS: Record = { + todo: { label: "To do", cls: "filter-status-todo" }, + progress: { label: "In Progress", cls: "filter-status-progress" }, + done: { label: "Completed", cls: "filter-status-done" }, +}; + +export default function FilterTable() { + const [filter, setFilter] = useState<"all" | Status>("all"); + + return ( +
+ {/* filter chips */} +
+ {FILTERS.map((f) => { + const active = filter === f.key; + return ( + + ); + })} +
+ + {/* table */} +
+
+
+ Task name + Date + Status + Advisor +
+ {ROWS.map((row) => { + const shown = filter === "all" || row.status === filter; + const pill = PILLS[row.status]; + return ( +
+
+
+ + {row.task} + + + {row.date} + + + + {pill.label} + + + + {row.owner} + +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/.chatui/fine-tune-card.tsx b/.chatui/fine-tune-card.tsx new file mode 100644 index 000000000..88eeab5b9 --- /dev/null +++ b/.chatui/fine-tune-card.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useRef, useState } from "react"; +import GlideMenu from "@/components/primitives/GlideMenu"; + +/* ───────────────────────────────────────────────────────── + * FINE-TUNE CARD — compact interactive inspector. + * Number fields scrub: hover the label for an ↔ cursor and + * drag to adjust, use ↑/↓ (⇧ for ×10), or type directly. + * ───────────────────────────────────────────────────────── */ + +function ScrubField({ + label, + value, + onChange, + min, + max, + step = 1, + suffix = "", + active, +}: { + label: string; + value: number; + onChange: (v: number) => void; + min: number; + max: number; + step?: number; + suffix?: string; + active?: boolean; +}) { + const drag = useRef<{ x: number; v: number } | null>(null); + const clamp = (v: number) => Math.min(max, Math.max(min, Math.round(v))); + + return ( + + ); +} + +const SEGMENTS = ["row", "col", "grid"] as const; + +function SegmentIcon({ kind }: { kind: string }) { + const dot = "size-1.5 rounded-[2px] border-[1.2px] border-current"; + if (kind === "row") + return {[0, 1, 2].map((i) => )}; + if (kind === "col") + return {[0, 1].map((i) => )}; + return ( + + {[0, 1, 2, 3].map((i) => )} + + ); +} + +export default function FineTuneCard() { + const [seg, setSeg] = useState(0); + const [width, setWidth] = useState(324); + const [height, setHeight] = useState(96); + const [radius, setRadius] = useState(28); + const [opacity, setOpacity] = useState(100); + const [menuOpen, setMenuOpen] = useState(false); + const [typeValue, setTypeValue] = useState("Select type"); + const done = + seg !== 0 || width !== 324 || height !== 96 || radius !== 28 || opacity !== 100 || typeValue !== "Select type"; + + return ( +
+ {/* header */} +
+ Flavor card + {done ? ( + + + + + Edited + + ) : ( + + + + + + + + Adjust + + + )} +
+ + {/* layout section */} +
+

Layout

+ {/* segmented control: gray track, raised white thumb */} +
+ + {SEGMENTS.map((s, i) => ( + + ))} +
+
+ + +
+
+ + +
+
+ + {/* interaction section */} +
+ Type +
+ + + {menuOpen && ( +
+ + {["Seasonal", "Classic", "Limited"].map((item) => ( + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/.chatui/flowchat.tsx b/.chatui/flowchat.tsx new file mode 100644 index 000000000..1d4024852 --- /dev/null +++ b/.chatui/flowchat.tsx @@ -0,0 +1,537 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useLayoutEffect } from "react"; + +/* ───────────────────────────────────────────────────────── + * FLOWCHART — an agent workflow on a dotted editor canvas. + * Two steps: a Trigger card and an If/Else condition card, + * joined by a measured connector. Cards drag anywhere on + * the canvas; the connector follows. Condition chips open + * real dropdowns (same menu as the PromptBar model picker). + * ───────────────────────────────────────────────────────── */ + +const PURPLE = "#9a5cff"; +const AMBER = "#f09a2f"; + +const mix = (hue: string, pct: number, base = "var(--surface)") => + `color-mix(in srgb, ${hue} ${pct}%, ${base})`; + +/* ── layout constants ── */ +const PAD_Y = 24; +const ROW_GAP = 64; +const PILL_OFFSET = 30; // kind pill + gap above a card + +type StepNode = { + id: string; + row: number; + x: number; // 0–1 center of the node + w: number; + kind?: { label: string; hue: string }; + hue?: string; + title?: string; + caption?: string; + condition?: boolean; // renders the if/else chip rows instead +}; + +const NODES: StepNode[] = [ + { + id: "trigger", + row: 0, + x: 0.5, + w: 300, + kind: { label: "Trigger", hue: PURPLE }, + hue: PURPLE, + title: "New order created", + caption: "Trigger when a new order is created", + }, + { + id: "cond", + row: 1, + x: 0.5, + w: 356, + kind: { label: "If / Else", hue: AMBER }, + condition: true, + }, +]; + +const EDGES = [{ from: "trigger", to: "cond" }]; + +/* estimated heights for the first paint; measured immediately after */ +const EST_H: Record = { trigger: 92, cond: 134 }; + +const PROPERTIES = ["flavor", "topping", "size", "scoops"]; +const FLAVORS = [ + { name: "Rocky Road", tag: "Classic" }, + { name: "Mint Chip", tag: "Classic" }, + { name: "Pistachio", tag: "Seasonal" }, + { name: "Bubblegum", tag: "Retro" }, +]; +const TOPPINGS = [ + { name: "Brown butter bourbon brittle crunch" }, + { name: "Rainbow sprinkles" }, + { name: "Hot fudge" }, + { name: "Candied pecans" }, +]; + +/* ── icons ── */ +function ConeIcon({ size = 16 }: { size?: number }) { + return ( + + + + + + ); +} + +function Chevron() { + return ( + + + + ); +} + +function Handle() { + return ( + + {[3, 8, 13].flatMap((y) => [ + , + , + ])} + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + +/* ── dropdown menu — same pattern as the PromptBar model picker ── */ +function Menu({ + items, + value, + width, + align, + onPick, +}: { + items: { name: string; tag?: string }[]; + value: string; + width: string; + align: "left" | "right"; + onPick: (name: string) => void; +}) { + const [hovered, setHovered] = useState(null); + const rowRefs = useRef<(HTMLButtonElement | null)[]>([]); + const [box, setBox] = useState<{ top: number; height: number } | null>(null); + + const valueIndex = items.findIndex((item) => item.name === value); + useLayoutEffect(() => { + const row = rowRefs.current[hovered ?? valueIndex]; + if (row) setBox({ top: row.offsetTop, height: row.offsetHeight }); + }, [hovered, valueIndex]); + + return ( +
setHovered(null)} + className={`absolute bottom-full z-20 mb-1.5 rounded-[10px] bg-surface p-1 shadow-raised ${width} + ${align === "right" ? "right-0" : "left-0"}`} + style={{ + animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both", + transformOrigin: align === "right" ? "bottom right" : "bottom left", + }} + > + + {items.map((item, i) => ( + + ))} +
+ ); +} + +/* ── chips used inside the condition card ── */ +function SourceChip() { + return ( + + + + + order + + ); +} + +function SelectChip({ + id, + value, + dot, + items, + width, + align = "left", + open, + onToggle, + onPick, +}: { + id: string; + value: string; + dot?: boolean; + items: { name: string; tag?: string }[]; + width: string; + align?: "left" | "right"; + open: boolean; + onToggle: (id: string) => void; + onPick: (id: string, name: string) => void; +}) { + return ( + + + {open && ( + onPick(id, name)} + /> + )} + + ); +} + +function ConditionBody() { + const [values, setValues] = useState>({ + prop1: "flavor", + val1: "Rocky Road", + prop2: "topping", + val2: "Brown butter bourbon brittle crunch", + }); + const [open, setOpen] = useState(null); + + /* click anywhere else closes the menu */ + useEffect(() => { + if (!open) return; + const close = (event: PointerEvent) => { + if (!(event.target as Element).closest("[data-ui]")) setOpen(null); + }; + document.addEventListener("pointerdown", close); + return () => document.removeEventListener("pointerdown", close); + }, [open]); + + const toggle = (id: string) => setOpen((current) => (current === id ? null : id)); + const pick = (id: string, name: string) => { + setValues((current) => ({ ...current, [id]: name })); + setOpen(null); + }; + + const chip = (id: string, items: { name: string; tag?: string }[], width: string, extra?: object) => ( + + ); + + return ( +
+
+ + If + + {chip("prop1", PROPERTIES.map((name) => ({ name })), "w-36")} + is + {chip("val1", FLAVORS, "w-44", { dot: true, align: "right" })} +
+
+ + and + + {chip("prop2", PROPERTIES.map((name) => ({ name })), "w-36")} + is + + {chip("val2", TOPPINGS, "w-64", { dot: true })} + +
+
+ ); +} + +function StepBody({ node }: { node: StepNode }) { + return ( +
+ + + + + {node.title} + {node.caption} + +
+ ); +} + +/* ── the canvas ── */ +export default function Flowchart() { + const canvasRef = useRef(null); + const nodeRefs = useRef(new Map()); + const [width, setWidth] = useState(0); + const [heights, setHeights] = useState>(EST_H); + const [selected, setSelected] = useState(null); + const [offsets, setOffsets] = useState>({}); + const drag = useRef<{ + id: string; + startX: number; + startY: number; + baseDx: number; + baseDy: number; + moved: boolean; + } | null>(null); + + useLayoutEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const measure = () => { + setWidth(canvas.clientWidth); + setHeights((prev) => { + const next = { ...prev }; + let changed = false; + nodeRefs.current.forEach((el, id) => { + const h = el.offsetHeight; + if (h && Math.abs(h - (next[id] ?? 0)) > 0.5) { + next[id] = h; + changed = true; + } + }); + return changed ? next : prev; + }); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(canvas); + nodeRefs.current.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, []); + + /* rows → y offsets from measured node heights */ + const rows = [...new Set(NODES.map((n) => n.row))].sort((a, b) => a - b); + const rowH = rows.map((r) => + Math.max(...NODES.filter((n) => n.row === r).map((n) => heights[n.id] ?? 90)), + ); + const rowY: number[] = []; + rows.forEach((_, i) => { + rowY[i] = i === 0 ? PAD_Y : rowY[i - 1] + rowH[i - 1] + ROW_GAP; + }); + const canvasH = rowY[rows.length - 1] + rowH[rows.length - 1] + PAD_Y; + + const cw = width || 480; + const place = (n: StepNode) => { + const w = Math.min(n.w, cw * 0.92); + const off = offsets[n.id]; + return { + w, + cx: n.x * cw + (off?.dx ?? 0), + top: rowY[rows.indexOf(n.row)] + (off?.dy ?? 0), + }; + }; + + /* card anchor points (pills sit above the card, so offset the top) */ + const anchors = (n: StepNode) => { + const { cx, top } = place(n); + return { + top: { x: cx, y: top + (n.kind ? PILL_OFFSET : 0) }, + bottom: { x: cx, y: top + (heights[n.id] ?? 90) }, + }; + }; + + const bezier = (edge: { from: string; to: string }) => { + const from = anchors(NODES.find((n) => n.id === edge.from)!).bottom; + const to = anchors(NODES.find((n) => n.id === edge.to)!).top; + const k = Math.min(Math.max(Math.abs(to.y - from.y) * 0.55, 24), 84); + return `M ${from.x} ${from.y} C ${from.x} ${from.y + k}, ${to.x} ${to.y - k}, ${to.x} ${to.y}`; + }; + + /* ── dragging ── */ + const onPointerDown = (node: StepNode) => (event: React.PointerEvent) => { + if ((event.target as Element).closest("[data-ui]")) return; + const off = offsets[node.id]; + drag.current = { + id: node.id, + startX: event.clientX, + startY: event.clientY, + baseDx: off?.dx ?? 0, + baseDy: off?.dy ?? 0, + moved: false, + }; + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + }; + + const onPointerMove = (node: StepNode) => (event: React.PointerEvent) => { + const d = drag.current; + if (!d || d.id !== node.id) return; + const dx = d.baseDx + event.clientX - d.startX; + const dy = d.baseDy + event.clientY - d.startY; + if (!d.moved && Math.hypot(dx - d.baseDx, dy - d.baseDy) < 3) return; + d.moved = true; + + /* keep the card inside the canvas */ + const { w } = place(node); + const h = heights[node.id] ?? 90; + const baseCx = node.x * cw; + const baseTop = rowY[rows.indexOf(node.row)]; + const cx = Math.min(Math.max(baseCx + dx, w / 2 + 8), cw - w / 2 - 8); + const top = Math.min(Math.max(baseTop + dy, 8), canvasH - h - 8); + setOffsets((current) => ({ ...current, [node.id]: { dx: cx - baseCx, dy: top - baseTop } })); + }; + + const onPointerUp = (node: StepNode) => () => { + const d = drag.current; + if (d?.id === node.id) { + /* a real drag shouldn't also toggle selection */ + if (d.moved) setTimeout(() => (drag.current = null), 0); + else drag.current = null; + } + }; + + const wasDragged = () => drag.current?.moved === true; + + const isLit = (edge: { from: string; to: string }) => + selected === edge.from || selected === edge.to; + + return ( +
+ {/* connectors */} + + {EDGES.map((edge) => ( + + ))} + + + {/* nodes */} + {NODES.map((node) => { + const { w, cx, top } = place(node); + const active = selected === node.id; + return ( +
{ + if (el) nodeRefs.current.set(node.id, el); + else nodeRefs.current.delete(node.id); + }} + onPointerDown={onPointerDown(node)} + onPointerMove={onPointerMove(node)} + onPointerUp={onPointerUp(node)} + className="absolute flex -translate-x-1/2 touch-none flex-col items-start gap-1.5" + style={{ left: cx, top, width: w, zIndex: drag.current?.id === node.id ? 2 : 1 }} + > + {node.kind && ( + + {node.kind.label} + + )} + {node.condition ? ( +
+ +
+ ) : ( + + )} +
+ ); + })} +
+ ); +} diff --git a/.chatui/loading-state.tsx b/.chatui/loading-state.tsx new file mode 100644 index 000000000..210b98f5e --- /dev/null +++ b/.chatui/loading-state.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/* ───────────────────────────────────────────────────────── + * LOADING STATE — pixel-grid loader for long-running work + * + * Variants: + * Drive — square cells, chevron wavefront driving right; + * the 650ms cycle is shorter than the sweep, so + * two fronts are always in flight + * Dots — same wavefront, circular cells + * Orbit — a comet lapping the grid perimeter + * Surfer — the Drive loader paired with a meme video below + * + * Paired with a shimmering label and a live elapsed timer + * in mono tabular figures. Reduced motion freezes the grid + * to its dim state; the timer still ticks. + * ───────────────────────────────────────────────────────── */ + +const chevron = Array.from({ length: 9 }, (_, i) => { + const r = Math.floor(i / 3), c = i % 3; + return (c + Math.abs(r - 1)) * 90; +}); + +const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3]; +const orbit = Array.from({ length: 9 }, (_, i) => { + const k = ORBIT_ORDER.indexOf(i); + return k === -1 ? null : k * 110; +}); + +const PATTERNS: Record = { + Drive: { delays: chevron, dur: 650, round: false }, + Dots: { delays: chevron, dur: 650, round: true }, + Orbit: { delays: orbit, dur: 950, round: false }, +}; + +function LoaderGrid({ + delays, + dur, + round, +}: { + delays: (number | null)[]; + dur: number; + round: boolean; +}) { + return ( + + {delays.map((delay, index) => ( + + ))} + + ); +} + +function useElapsed() { + const [ds, setDs] = useState(0); + useEffect(() => { + const t = setInterval(() => setDs((d) => d + 1), 100); + return () => clearInterval(t); + }, []); + const total = ds / 10; + if (total < 60) return `${total.toFixed(1)}s`; + return `${Math.floor(total / 60)}m ${(total % 60).toFixed(1)}s`; +} + +export default function LoadingState({ + label, + variant = "Drive", + /** the meme feed for the Surfer variant; drop the file in /public to light it up */ + videoSrc = "/subway-surfers.mp4", +}: { + label?: string; + variant?: string; + videoSrc?: string; +}) { + const elapsed = useElapsed(); + const surfer = variant === "Surfer"; + const resolvedLabel = label ?? (surfer ? "Subway surfing" : "Churning"); + const [videoOk, setVideoOk] = useState(true); + const { delays, dur, round } = PATTERNS[variant] ?? PATTERNS.Drive; + + const labelEl = ( + + {resolvedLabel} + + ); + const elapsedEl = {elapsed}; + + if (surfer) { + return ( +
+
+ + {labelEl} + {elapsedEl} +
+ + {/* the context card follows the status text it is illustrating */} +
+
+ {videoOk ? ( +
+
+
+ ); + } + + return ( +
+ + {labelEl} + {elapsedEl} +
+ ); +} diff --git a/.chatui/prompt-bar.tsx b/.chatui/prompt-bar.tsx new file mode 100644 index 000000000..f485ead58 --- /dev/null +++ b/.chatui/prompt-bar.tsx @@ -0,0 +1,701 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createShader, playSweep, accentChain, ACCENTS } from "glimm"; + +/* The built-in "prism" palette is only cyan→indigo→magenta, so a sweep + * reads as blue/purple. Build a true full-spectrum rainbow instead. */ +const RAINBOW = accentChain([ + ACCENTS.red, + ACCENTS.orange, + ACCENTS.yellow, + ACCENTS.green, + ACCENTS.cyan, + ACCENTS.blue, + ACCENTS.purple, +]); + +/* ───────────────────────────────────────────────────────── + * PROMPT BAR + * A composer with real controls: attach, @ data sources, + * / commands, a model picker, dictation, and send. + * Type @ or / to open the menus; ↑↓ + Enter to pick. + * Variants: Rounded (card radius) · Pill (full radius). + * ───────────────────────────────────────────────────────── */ + +function Icon({ children, size = 15, strokeWidth = 1.8 }: { children: React.ReactNode; size?: number; strokeWidth?: number }) { + return ( + + ); +} + +const GLYPHS: Record = { + clip: , + chart: , + layers: , + globe: , +}; + +/* real product marks, inline so the file stays self-contained */ +const BRANDS: Record = { + figma: ( + + ), + slack: ( + + ), + gmail: ( + + ), +}; + +type Source = { + key: string; + name: string; + desc: string; + glyph?: string; + brand?: string; + attach?: boolean; + connect?: boolean; +}; + +const SOURCES: Source[] = [ + { key: "attach", name: "Add photos & files", desc: "Upload from your computer", glyph: "clip", attach: true }, + { key: "scoop", name: "Scoop Data", desc: "Sales & churn metrics", glyph: "chart" }, + { key: "flavors", name: "Flavor records", desc: "26 makers, tags, links", glyph: "layers" }, + { key: "web", name: "Web search", desc: "Real-time news and info", glyph: "globe" }, + { key: "figma", name: "Figma", desc: "Design-to-code workflows", brand: "figma" }, + { key: "slack", name: "Slack", desc: "Read and manage Slack", brand: "slack" }, + { key: "gmail", name: "Gmail", desc: "Read and manage Gmail", brand: "gmail", connect: true }, +]; + +const COMMANDS = [ + { key: "compare", name: "/compare", desc: "Flavor vs. last summer" }, + { key: "churn-plan", name: "/churn-plan", desc: "Draft a churn schedule" }, + { key: "restock", name: "/restock", desc: "Build a reorder list" }, + { key: "draft-email", name: "/draft-email", desc: "Write a supplier email" }, + { key: "summarize", name: "/summarize", desc: "Digest the thread so far" }, +]; + +const MODELS = [ + { key: "sprinkles-5", name: "Sprinkles 5", tag: "Flagship" }, + { key: "vanilla-1", name: "Vanilla 1", tag: "Basic" }, + { key: "freezer-burn", name: "Freezer Burn 0.4", tag: "Stale" }, +]; + +const FILES = ["flavor-chart.png", "summer-menu.pdf", "pos-export.csv"]; +const DICTATION = "Compare pistachio weekends to last summer"; + +/* self-running demo: walk the @ menu, then the / menu, and repeat. + * Any pointer or key interaction hands control to the user. */ +const AUTO_STEPS: { + draft: string; + active?: number; + connect?: boolean; + modelOpen?: boolean; + model?: string; + hold: number; +}[] = [ + { draft: "", connect: false, model: "vanilla-1", hold: 1100 }, + { draft: "@", active: 0, hold: 900 }, + { draft: "@", active: 1, hold: 620 }, + { draft: "@", active: 4, hold: 620 }, + { draft: "@", active: 6, hold: 700 }, + { draft: "@", active: 6, connect: true, hold: 1000 }, + { draft: "", hold: 700 }, + { draft: "/", active: 0, hold: 900 }, + { draft: "/", active: 1, hold: 620 }, + { draft: "/", active: 3, hold: 1000 }, + { draft: "", hold: 800 }, + // open the model picker and upgrade to the flagship → rainbow sweep + { draft: "", modelOpen: true, hold: 1200 }, + { draft: "", model: "sprinkles-5", hold: 2400 }, + { draft: "", hold: 900 }, + ]; + +/* the last @word or /word being typed, if any */ +function parseToken(draft: string): { kind: "at" | "slash"; query: string; start: number } | null { + const match = /(^|\s)([@/])([\w-]*)$/.exec(draft); + if (!match) return null; + return { + kind: match[2] === "@" ? "at" : "slash", + query: match[3].toLowerCase(), + start: match.index + match[1].length, + }; +} + +export default function PromptBar({ + variant = "Rounded", + demo = true, + tall = false, + placeholder, + onSend, +}: { + variant?: string; + /** the self-running walkthrough; turn off when embedding in a real surface */ + demo?: boolean; + /** hero sizing: a multi-line input with controls on their own row */ + tall?: boolean; + placeholder?: string; + onSend?: (text: string) => void; +}) { + const pill = variant === "Pill"; + const [draft, setDraft] = useState(""); + const [dismissed, setDismissed] = useState(false); + const [plusOpen, setPlusOpen] = useState(false); + const [modelOpen, setModelOpen] = useState(false); + const [model, setModel] = useState(MODELS[1]); + const [attachments, setAttachments] = useState([]); + const [connected, setConnected] = useState(false); + const [active, setActive] = useState(0); + const [listening, setListening] = useState(false); + const [auto, setAuto] = useState(demo); + const [autoStep, setAutoStep] = useState(0); + const [expanded, setExpanded] = useState(false); + const wide = expanded || tall; + const [rowBox, setRowBox] = useState<{ top: number; height: number } | null>(null); + const [engaged, setEngaged] = useState(false); + const [modelBox, setModelBox] = useState<{ top: number; height: number } | null>(null); + const [modelHovered, setModelHovered] = useState(null); + const [modelMenuLeft, setModelMenuLeft] = useState(0); + const [modelMenuBottom, setModelMenuBottom] = useState(0); + const composerAnchorRef = useRef(null); + const controlsRef = useRef(null); + const inputRef = useRef(null); + const measureRef = useRef(null); + const modelRef = useRef(null); + const rowRefs = useRef<(HTMLButtonElement | null)[]>([]); + const modelRowRefs = useRef<(HTMLButtonElement | null)[]>([]); + const glimmRef = useRef(null); + const shaderRef = useRef | null>(null); + const sweepingRef = useRef(false); + + /* hand control to the user: stop the demo loop, and when they aim at + * the input itself, clear the demo's leftover draft for a clean start */ + const takeOver = (event: { target: EventTarget | null }) => { + setAuto(false); + if (auto && event.target === inputRef.current) setDraft(""); + }; + + const token = dismissed ? null : parseToken(draft); + const menu: "at" | "slash" | null = plusOpen ? "at" : token?.kind ?? null; + const query = plusOpen ? "" : token?.query ?? ""; + + const rows: { key: string; name: string; desc: string }[] = + menu === "at" + ? SOURCES.filter((s) => s.name.toLowerCase().includes(query)) + : menu === "slash" + ? COMMANDS.filter((c) => c.name.slice(1).startsWith(query)) + : []; + + useEffect(() => { + setActive(0); + setEngaged(false); + }, [menu, query]); + + /* a single highlight glides to the active row instead of each row + * toggling its own background — matches the gliding pill in the nav */ + useLayoutEffect(() => { + const target = rowRefs.current[active]; + if (target) setRowBox({ top: target.offsetTop, height: target.offsetHeight }); + }, [menu, query, active, connected, rows.length]); + + /* same gliding highlight in the model menu — floats to the hovered + * row, falling back to the currently-selected model */ + const modelIndex = MODELS.findIndex((m) => m.key === model.key); + useLayoutEffect(() => { + if (!modelOpen) return; + const target = modelRowRefs.current[modelHovered ?? modelIndex]; + if (target) setModelBox({ top: target.offsetTop, height: target.offsetHeight }); + }, [modelOpen, modelHovered, modelIndex]); + + /* The menu is outside the clipped composer, so align it to the model + * trigger by measurement instead of pinning it to the far-right edge. */ + useLayoutEffect(() => { + if (!modelOpen || !composerAnchorRef.current || !modelRef.current) return; + const anchorRect = composerAnchorRef.current.getBoundingClientRect(); + const triggerRect = modelRef.current.getBoundingClientRect(); + setModelMenuLeft(Math.max(0, Math.min(triggerRect.left - anchorRect.left, anchorRect.width - 176))); + setModelMenuBottom(anchorRect.bottom - triggerRect.top + 8); + }, [modelOpen, wide, model.name]); + + useEffect(() => { + if (!modelOpen) setModelHovered(null); + }, [modelOpen]); + + /* Build the shader with a pinned hue phase. createShader seeds its + * internal hueShift from Math.random(), which made the sweep a different + * colour on every reload — pin it so the rainbow is identical each time. */ + const makeShader = () => { + const canvas = glimmRef.current; + if (!canvas) return null; + const random = Math.random; + Math.random = () => 0; + try { + return createShader({ + canvas, + palette: RAINBOW, + direction: "ltr", + bandTight: 10, + swellAmount: 0.85, + }); + } finally { + Math.random = random; + } + }; + + /* Glimm shader lives inside the composer, invisible at rest. Selecting + * the flagship model fires a one-shot rainbow sweep across the interior. */ + useEffect(() => { + shaderRef.current = makeShader(); + return () => { + shaderRef.current?.destroy(); + shaderRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const celebrate = () => { + if (sweepingRef.current) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + // Recreate the shader per sweep so uTime restarts at 0 — the hue phase + // (which drifts with time) is then identical on every trigger. + shaderRef.current?.destroy(); + const shader = makeShader(); + shaderRef.current = shader; + if (!shader) return; + sweepingRef.current = true; + const sweep = playSweep(shader, { + palette: RAINBOW, + direction: "ltr", + sweepMs: 570, + outroMs: 80, + peakAlpha: 1.3, + bandTight: 10, + brightness: 1.4, + swellAmount: 1, + waveSpeed: 1.8, + easing: "easeOutExpo", + }); + sweep.done.finally(() => { + sweepingRef.current = false; + }); + }; + + const selectModel = (next: (typeof MODELS)[number]) => { + setModel(next); + setModelOpen(false); + if (next.key === "sprinkles-5") celebrate(); + }; + + /* autoplay: apply the current step, then advance after its hold */ + useEffect(() => { + if (!auto) return; + const step = AUTO_STEPS[autoStep % AUTO_STEPS.length]; + setDraft(step.draft); + if (step.active !== undefined) setActive(step.active); + if (step.connect !== undefined) setConnected(step.connect); + if (step.modelOpen !== undefined) setModelOpen(step.modelOpen); + if (step.model) { + const next = MODELS.find((m) => m.key === step.model); + if (next) selectModel(next); + } + const t = setTimeout(() => setAutoStep((s) => s + 1), step.hold); + return () => clearTimeout(t); + }, [auto, autoStep]); + + /* dictation resolves after a beat, like a real transcript landing */ + useEffect(() => { + if (!listening) return; + const t = setTimeout(() => { + setDraft((current) => (current ? `${current.trimEnd()} ${DICTATION}` : DICTATION)); + setListening(false); + inputRef.current?.focus(); + }, 2200); + return () => clearTimeout(t); + }, [listening]); + + /* Move wrapped text above the controls, then grow to a compact maximum. */ + useLayoutEffect(() => { + const input = inputRef.current; + const controls = controlsRef.current; + const measure = measureRef.current; + const modelButton = modelRef.current; + if (!input || !controls || !measure || !modelButton) return; + + const fixedControlsWidth = 28 * 3 + modelButton.offsetWidth; + const inlineGaps = 4 * 4; + const inlineInputWidth = controls.clientWidth - fixedControlsWidth - inlineGaps; + const needsFullWidth = draft.includes("\n") || measure.offsetWidth + 8 > inlineInputWidth; + if (needsFullWidth !== expanded) { + setExpanded(needsFullWidth); + } + + const minHeight = 28; + const maxHeight = 100; + input.style.height = "0px"; + const contentHeight = input.scrollHeight; + input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px`; + input.style.overflowY = contentHeight > maxHeight ? "auto" : "hidden"; + }, [draft, expanded]); + + /* clicking anywhere outside the composer closes the open menus */ + useEffect(() => { + if (!modelOpen && !plusOpen) return; + const close = (event: PointerEvent) => { + if (!(event.target as Element).closest("[data-promptbar]")) { + setModelOpen(false); + setPlusOpen(false); + } + }; + document.addEventListener("pointerdown", close); + return () => document.removeEventListener("pointerdown", close); + }, [modelOpen, plusOpen]); + + const closeMenus = () => { + setPlusOpen(false); + setModelOpen(false); + }; + + const pick = (row: { key: string; name: string }) => { + const source = SOURCES.find((s) => s.key === row.key); + if (source?.attach) { + setAttachments((current) => [...current, FILES[current.length % FILES.length]]); + if (token) setDraft(draft.slice(0, token.start)); + } else if (menu === "at") { + setDraft(`${token ? draft.slice(0, token.start) : draft}@${row.name} `); + } else { + setDraft(`${token ? draft.slice(0, token.start) : draft}${row.name} `); + } + setPlusOpen(false); + setDismissed(false); + inputRef.current?.focus(); + }; + + const canSend = draft.trim().length > 0 || attachments.length > 0; + const send = () => { + if (!canSend) return; + onSend?.(draft.trim()); + setDraft(""); + setAttachments([]); + closeMenus(); + }; + + return ( +
+ {/* composer is the anchor — menus grow up from its top edge */} +
+ {/* ── @ / slash menu ─────────────────────────────── */} + {menu && ( +
setEngaged(false)} + className="absolute inset-x-0 bottom-full z-10 mb-2 rounded-[10px] bg-surface p-1 shadow-raised" + style={{ animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "bottom center" }} + > + {/* single gliding highlight — appears once a row is hovered */} + 0 ? 1 : 0, + transition: + "top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease", + }} + /> + {rows.map((row, i) => { + const source = menu === "at" ? SOURCES.find((s) => s.key === row.key) : undefined; + return ( + + ); + })} + {rows.length === 0 && ( +
+ No matches for “{query}” +
+ )} +
+ {menu === "at" ? "Type to search sources & files" : "Type to search commands"} +
+
+ )} + + {/* ── model menu ─────────────────────────────────── */} + {modelOpen && ( +
setModelHovered(null)} + className="absolute z-10 w-44 rounded-[10px] bg-surface p-1 shadow-raised" + style={{ left: modelMenuLeft, bottom: modelMenuBottom, animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "bottom left" }} + > + {/* single gliding highlight — floats to the hovered / selected row */} + + {MODELS.map((m, i) => ( + + ))} +
+ )} + + {/* ── composer ───────────────────────────────────── */} +
0 || wide ? "rounded-[24px]" : "rounded-full") : tall ? "rounded-[22px]" : "rounded-[14px]" + }`} + > + {/* rainbow glimm sweep — plays across the interior on model change. + explicit w/h: a is a replaced element and won't stretch + to inset-0 alone, which feeds back into the shader's ResizeObserver. */} +