Files
inkreach-agent/docs/ui-ref/beautifului/prompt-bar.json
T

21 lines
32 KiB
JSON

{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "prompt-bar",
"type": "registry:component",
"title": "Prompt Bar",
"description": "Composer with @ sources, / commands, model picker, and dictation.",
"dependencies": [
"glimm"
],
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/primitives/PromptBar.tsx",
"content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { createShader, playSweep, accentChain, ACCENTS } from \"glimm\";\n\n/* The built-in \"prism\" palette is only cyan→indigo→magenta, so a sweep\n * reads as blue/purple. Build a true full-spectrum rainbow instead. */\nconst RAINBOW = accentChain([\n ACCENTS.red,\n ACCENTS.orange,\n ACCENTS.yellow,\n ACCENTS.green,\n ACCENTS.cyan,\n ACCENTS.blue,\n ACCENTS.purple,\n]);\n\n/* ─────────────────────────────────────────────────────────\n * PROMPT BAR\n * A composer with real controls: attach, @ data sources,\n * / commands, a model picker, dictation, and send.\n * Type @ or / to open the menus; ↑↓ + Enter to pick.\n * Variants: Rounded (card radius) · Pill (full radius).\n * ───────────────────────────────────────────────────────── */\n\nfunction Icon({ children, size = 15, strokeWidth = 1.8 }: { children: React.ReactNode; size?: number; strokeWidth?: number }) {\n return (\n <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={strokeWidth} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n {children}\n </svg>\n );\n}\n\nconst GLYPHS: Record<string, React.ReactNode> = {\n clip: <path d=\"m21.4 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48\" />,\n chart: <path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\" />,\n layers: <g><path d=\"M12 2 2 7l10 5 10-5-10-5z\" /><path d=\"M2 17l10 5 10-5M2 12l10 5 10-5\" /></g>,\n globe: <g><circle cx=\"12\" cy=\"12\" r=\"10\" /><path d=\"M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\" /></g>,\n};\n\n/* real product marks, inline so the file stays self-contained */\nconst BRANDS: Record<string, React.ReactNode> = {\n figma: (\n <svg width=\"11\" height=\"16\" viewBox=\"0 0 38 57\" aria-hidden=\"true\">\n <path d=\"M9.5 57A9.5 9.5 0 0 0 19 47.5V38H9.5a9.5 9.5 0 0 0 0 19z\" fill=\"#0ACF83\" />\n <path d=\"M0 28.5A9.5 9.5 0 0 1 9.5 19H19v19H9.5A9.5 9.5 0 0 1 0 28.5z\" fill=\"#A259FF\" />\n <path d=\"M0 9.5A9.5 9.5 0 0 1 9.5 0H19v19H9.5A9.5 9.5 0 0 1 0 9.5z\" fill=\"#F24E1E\" />\n <path d=\"M19 0h9.5a9.5 9.5 0 1 1 0 19H19V0z\" fill=\"#FF7262\" />\n <path d=\"M38 28.5a9.5 9.5 0 1 1-19 0 9.5 9.5 0 0 1 19 0z\" fill=\"#1ABCFE\" />\n </svg>\n ),\n slack: (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 127 127\" aria-hidden=\"true\">\n <path d=\"M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z\" fill=\"#E01E5A\" />\n <path d=\"M47 27.2c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.7 39.7.8 47 .8c7.3 0 13.2 5.9 13.2 13.2v13.2H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.3.7 54.4.7 47.1c0-7.3 5.9-13.2 13.2-13.2H47z\" fill=\"#36C5F0\" />\n <path d=\"M99.9 47.1c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V47.1zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.9C66.9 6.6 72.8.7 80.1.7c7.3 0 13.2 5.9 13.2 13.2v33.2z\" fill=\"#2EB67D\" />\n <path d=\"M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z\" fill=\"#ECB22E\" />\n </svg>\n ),\n gmail: (\n <svg width=\"15\" height=\"12\" viewBox=\"0 0 256 193\" aria-hidden=\"true\">\n <path d=\"M58.182 192.05V93.14L27.507 65.077 0 49.504v125.091c0 9.658 7.825 17.455 17.455 17.455h40.727Z\" fill=\"#4285F4\" />\n <path d=\"M197.818 192.05h40.727c9.659 0 17.455-7.826 17.455-17.455V49.505l-31.156 17.837-27.026 25.798v98.91Z\" fill=\"#34A853\" />\n <path d=\"m58.182 93.14-4.174-38.647 4.174-36.989L128 69.868l69.818-52.364 4.669 34.992-4.669 40.644L128 145.504 58.182 93.14Z\" fill=\"#EA4335\" />\n <path d=\"M197.818 17.504V93.14L256 49.504V26.231c0-21.585-24.64-33.89-41.89-20.945l-16.292 12.218Z\" fill=\"#FBBC04\" />\n <path d=\"m0 49.504 26.759 20.07L58.182 93.14V17.504L41.89 5.286C24.61-7.66 0 4.646 0 26.23v23.273Z\" fill=\"#C5221F\" />\n </svg>\n ),\n};\n\ntype Source = {\n key: string;\n name: string;\n desc: string;\n glyph?: string;\n brand?: string;\n attach?: boolean;\n connect?: boolean;\n};\n\nconst SOURCES: Source[] = [\n { key: \"attach\", name: \"Add photos & files\", desc: \"Upload from your computer\", glyph: \"clip\", attach: true },\n { key: \"scoop\", name: \"Scoop Data\", desc: \"Sales & churn metrics\", glyph: \"chart\" },\n { key: \"flavors\", name: \"Flavor records\", desc: \"26 makers, tags, links\", glyph: \"layers\" },\n { key: \"web\", name: \"Web search\", desc: \"Real-time news and info\", glyph: \"globe\" },\n { key: \"figma\", name: \"Figma\", desc: \"Design-to-code workflows\", brand: \"figma\" },\n { key: \"slack\", name: \"Slack\", desc: \"Read and manage Slack\", brand: \"slack\" },\n { key: \"gmail\", name: \"Gmail\", desc: \"Read and manage Gmail\", brand: \"gmail\", connect: true },\n];\n\nconst COMMANDS = [\n { key: \"compare\", name: \"/compare\", desc: \"Flavor vs. last summer\" },\n { key: \"churn-plan\", name: \"/churn-plan\", desc: \"Draft a churn schedule\" },\n { key: \"restock\", name: \"/restock\", desc: \"Build a reorder list\" },\n { key: \"draft-email\", name: \"/draft-email\", desc: \"Write a supplier email\" },\n { key: \"summarize\", name: \"/summarize\", desc: \"Digest the thread so far\" },\n];\n\nconst MODELS = [\n { key: \"sprinkles-5\", name: \"Sprinkles 5\", tag: \"Flagship\" },\n { key: \"vanilla-1\", name: \"Vanilla 1\", tag: \"Basic\" },\n { key: \"freezer-burn\", name: \"Freezer Burn 0.4\", tag: \"Stale\" },\n];\n\nconst FILES = [\"flavor-chart.png\", \"summer-menu.pdf\", \"pos-export.csv\"];\nconst DICTATION = \"Compare pistachio weekends to last summer\";\n\n/* self-running demo: walk the @ menu, then the / menu, and repeat.\n * Any pointer or key interaction hands control to the user. */\nconst AUTO_STEPS: {\n draft: string;\n active?: number;\n connect?: boolean;\n modelOpen?: boolean;\n model?: string;\n hold: number;\n}[] = [\n { draft: \"\", connect: false, model: \"vanilla-1\", hold: 1100 },\n { draft: \"@\", active: 0, hold: 900 },\n { draft: \"@\", active: 1, hold: 620 },\n { draft: \"@\", active: 4, hold: 620 },\n { draft: \"@\", active: 6, hold: 700 },\n { draft: \"@\", active: 6, connect: true, hold: 1000 },\n { draft: \"\", hold: 700 },\n { draft: \"/\", active: 0, hold: 900 },\n { draft: \"/\", active: 1, hold: 620 },\n { draft: \"/\", active: 3, hold: 1000 },\n { draft: \"\", hold: 800 },\n // open the model picker and upgrade to the flagship → rainbow sweep\n { draft: \"\", modelOpen: true, hold: 1200 },\n { draft: \"\", model: \"sprinkles-5\", hold: 2400 },\n { draft: \"\", hold: 900 },\n];\n\n/* the last @word or /word being typed, if any */\nfunction parseToken(draft: string): { kind: \"at\" | \"slash\"; query: string; start: number } | null {\n const match = /(^|\\s)([@/])([\\w-]*)$/.exec(draft);\n if (!match) return null;\n return {\n kind: match[2] === \"@\" ? \"at\" : \"slash\",\n query: match[3].toLowerCase(),\n start: match.index + match[1].length,\n };\n}\n\nexport default function PromptBar({\n variant = \"Rounded\",\n demo = true,\n tall = false,\n placeholder,\n onSend,\n}: {\n variant?: string;\n /** the self-running walkthrough; turn off when embedding in a real surface */\n demo?: boolean;\n /** hero sizing: a multi-line input with controls on their own row */\n tall?: boolean;\n placeholder?: string;\n onSend?: (text: string) => void;\n}) {\n const pill = variant === \"Pill\";\n const [draft, setDraft] = useState(\"\");\n const [dismissed, setDismissed] = useState(false);\n const [plusOpen, setPlusOpen] = useState(false);\n const [modelOpen, setModelOpen] = useState(false);\n const [model, setModel] = useState(MODELS[1]);\n const [attachments, setAttachments] = useState<string[]>([]);\n const [connected, setConnected] = useState(false);\n const [active, setActive] = useState(0);\n const [listening, setListening] = useState(false);\n const [auto, setAuto] = useState(demo);\n const [autoStep, setAutoStep] = useState(0);\n const [expanded, setExpanded] = useState(false);\n const wide = expanded || tall;\n const [rowBox, setRowBox] = useState<{ top: number; height: number } | null>(null);\n const [engaged, setEngaged] = useState(false);\n const [modelBox, setModelBox] = useState<{ top: number; height: number } | null>(null);\n const [modelHovered, setModelHovered] = useState<number | null>(null);\n const [modelMenuLeft, setModelMenuLeft] = useState(0);\n const [modelMenuBottom, setModelMenuBottom] = useState(0);\n const composerAnchorRef = useRef<HTMLDivElement>(null);\n const controlsRef = useRef<HTMLDivElement>(null);\n const inputRef = useRef<HTMLTextAreaElement>(null);\n const measureRef = useRef<HTMLSpanElement>(null);\n const modelRef = useRef<HTMLButtonElement>(null);\n const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const modelRowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const glimmRef = useRef<HTMLCanvasElement>(null);\n const shaderRef = useRef<ReturnType<typeof createShader> | null>(null);\n const sweepingRef = useRef(false);\n\n /* hand control to the user: stop the demo loop, and when they aim at\n * the input itself, clear the demo's leftover draft for a clean start */\n const takeOver = (event: { target: EventTarget | null }) => {\n setAuto(false);\n if (auto && event.target === inputRef.current) setDraft(\"\");\n };\n\n const token = dismissed ? null : parseToken(draft);\n const menu: \"at\" | \"slash\" | null = plusOpen ? \"at\" : token?.kind ?? null;\n const query = plusOpen ? \"\" : token?.query ?? \"\";\n\n const rows: { key: string; name: string; desc: string }[] =\n menu === \"at\"\n ? SOURCES.filter((s) => s.name.toLowerCase().includes(query))\n : menu === \"slash\"\n ? COMMANDS.filter((c) => c.name.slice(1).startsWith(query))\n : [];\n\n useEffect(() => {\n setActive(0);\n setEngaged(false);\n }, [menu, query]);\n\n /* a single highlight glides to the active row instead of each row\n * toggling its own background — matches the gliding pill in the nav */\n useLayoutEffect(() => {\n const target = rowRefs.current[active];\n if (target) setRowBox({ top: target.offsetTop, height: target.offsetHeight });\n }, [menu, query, active, connected, rows.length]);\n\n /* same gliding highlight in the model menu — floats to the hovered\n * row, falling back to the currently-selected model */\n const modelIndex = MODELS.findIndex((m) => m.key === model.key);\n useLayoutEffect(() => {\n if (!modelOpen) return;\n const target = modelRowRefs.current[modelHovered ?? modelIndex];\n if (target) setModelBox({ top: target.offsetTop, height: target.offsetHeight });\n }, [modelOpen, modelHovered, modelIndex]);\n\n /* The menu is outside the clipped composer, so align it to the model\n * trigger by measurement instead of pinning it to the far-right edge. */\n useLayoutEffect(() => {\n if (!modelOpen || !composerAnchorRef.current || !modelRef.current) return;\n const anchorRect = composerAnchorRef.current.getBoundingClientRect();\n const triggerRect = modelRef.current.getBoundingClientRect();\n setModelMenuLeft(Math.max(0, Math.min(triggerRect.left - anchorRect.left, anchorRect.width - 176)));\n setModelMenuBottom(anchorRect.bottom - triggerRect.top + 8);\n }, [modelOpen, wide, model.name]);\n\n useEffect(() => {\n if (!modelOpen) setModelHovered(null);\n }, [modelOpen]);\n\n /* Build the shader with a pinned hue phase. createShader seeds its\n * internal hueShift from Math.random(), which made the sweep a different\n * colour on every reload — pin it so the rainbow is identical each time. */\n const makeShader = () => {\n const canvas = glimmRef.current;\n if (!canvas) return null;\n const random = Math.random;\n Math.random = () => 0;\n try {\n return createShader({\n canvas,\n palette: RAINBOW,\n direction: \"ltr\",\n bandTight: 10,\n swellAmount: 0.85,\n });\n } finally {\n Math.random = random;\n }\n };\n\n /* Glimm shader lives inside the composer, invisible at rest. Selecting\n * the flagship model fires a one-shot rainbow sweep across the interior. */\n useEffect(() => {\n shaderRef.current = makeShader();\n return () => {\n shaderRef.current?.destroy();\n shaderRef.current = null;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n const celebrate = () => {\n if (sweepingRef.current) return;\n if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n // Recreate the shader per sweep so uTime restarts at 0 — the hue phase\n // (which drifts with time) is then identical on every trigger.\n shaderRef.current?.destroy();\n const shader = makeShader();\n shaderRef.current = shader;\n if (!shader) return;\n sweepingRef.current = true;\n const sweep = playSweep(shader, {\n palette: RAINBOW,\n direction: \"ltr\",\n sweepMs: 570,\n outroMs: 80,\n peakAlpha: 1.3,\n bandTight: 10,\n brightness: 1.4,\n swellAmount: 1,\n waveSpeed: 1.8,\n easing: \"easeOutExpo\",\n });\n sweep.done.finally(() => {\n sweepingRef.current = false;\n });\n };\n\n const selectModel = (next: (typeof MODELS)[number]) => {\n setModel(next);\n setModelOpen(false);\n if (next.key === \"sprinkles-5\") celebrate();\n };\n\n /* autoplay: apply the current step, then advance after its hold */\n useEffect(() => {\n if (!auto) return;\n const step = AUTO_STEPS[autoStep % AUTO_STEPS.length];\n setDraft(step.draft);\n if (step.active !== undefined) setActive(step.active);\n if (step.connect !== undefined) setConnected(step.connect);\n if (step.modelOpen !== undefined) setModelOpen(step.modelOpen);\n if (step.model) {\n const next = MODELS.find((m) => m.key === step.model);\n if (next) selectModel(next);\n }\n const t = setTimeout(() => setAutoStep((s) => s + 1), step.hold);\n return () => clearTimeout(t);\n }, [auto, autoStep]);\n\n /* dictation resolves after a beat, like a real transcript landing */\n useEffect(() => {\n if (!listening) return;\n const t = setTimeout(() => {\n setDraft((current) => (current ? `${current.trimEnd()} ${DICTATION}` : DICTATION));\n setListening(false);\n inputRef.current?.focus();\n }, 2200);\n return () => clearTimeout(t);\n }, [listening]);\n\n /* Move wrapped text above the controls, then grow to a compact maximum. */\n useLayoutEffect(() => {\n const input = inputRef.current;\n const controls = controlsRef.current;\n const measure = measureRef.current;\n const modelButton = modelRef.current;\n if (!input || !controls || !measure || !modelButton) return;\n\n const fixedControlsWidth = 28 * 3 + modelButton.offsetWidth;\n const inlineGaps = 4 * 4;\n const inlineInputWidth = controls.clientWidth - fixedControlsWidth - inlineGaps;\n const needsFullWidth = draft.includes(\"\\n\") || measure.offsetWidth + 8 > inlineInputWidth;\n if (needsFullWidth !== expanded) {\n setExpanded(needsFullWidth);\n }\n\n const minHeight = 28;\n const maxHeight = 100;\n input.style.height = \"0px\";\n const contentHeight = input.scrollHeight;\n input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px`;\n input.style.overflowY = contentHeight > maxHeight ? \"auto\" : \"hidden\";\n }, [draft, expanded]);\n\n /* clicking anywhere outside the composer closes the open menus */\n useEffect(() => {\n if (!modelOpen && !plusOpen) return;\n const close = (event: PointerEvent) => {\n if (!(event.target as Element).closest(\"[data-promptbar]\")) {\n setModelOpen(false);\n setPlusOpen(false);\n }\n };\n document.addEventListener(\"pointerdown\", close);\n return () => document.removeEventListener(\"pointerdown\", close);\n }, [modelOpen, plusOpen]);\n\n const closeMenus = () => {\n setPlusOpen(false);\n setModelOpen(false);\n };\n\n const pick = (row: { key: string; name: string }) => {\n const source = SOURCES.find((s) => s.key === row.key);\n if (source?.attach) {\n setAttachments((current) => [...current, FILES[current.length % FILES.length]]);\n if (token) setDraft(draft.slice(0, token.start));\n } else if (menu === \"at\") {\n setDraft(`${token ? draft.slice(0, token.start) : draft}@${row.name} `);\n } else {\n setDraft(`${token ? draft.slice(0, token.start) : draft}${row.name} `);\n }\n setPlusOpen(false);\n setDismissed(false);\n inputRef.current?.focus();\n };\n\n const canSend = draft.trim().length > 0 || attachments.length > 0;\n const send = () => {\n if (!canSend) return;\n onSend?.(draft.trim());\n setDraft(\"\");\n setAttachments([]);\n closeMenus();\n };\n\n return (\n <div\n data-promptbar\n className={demo ? \"flex min-h-[384px] w-full max-w-105 flex-col justify-end pb-8\" : \"w-full\"}\n onPointerDownCapture={takeOver}\n onKeyDownCapture={takeOver}\n >\n {/* composer is the anchor — menus grow up from its top edge */}\n <div ref={composerAnchorRef} className=\"relative\">\n {/* ── @ / slash menu ─────────────────────────────── */}\n {menu && (\n <div\n onMouseLeave={() => setEngaged(false)}\n className=\"absolute inset-x-0 bottom-full z-10 mb-2 rounded-[10px] bg-surface p-1 shadow-raised\"\n style={{ animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\", transformOrigin: \"bottom center\" }}\n >\n {/* single gliding highlight — appears once a row is hovered */}\n <span\n aria-hidden\n className=\"pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover\"\n style={{\n top: rowBox?.top ?? 0,\n height: rowBox?.height ?? 0,\n opacity: rowBox && engaged && rows.length > 0 ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {rows.map((row, i) => {\n const source = menu === \"at\" ? SOURCES.find((s) => s.key === row.key) : undefined;\n return (\n <button\n key={row.key}\n type=\"button\"\n ref={(el) => {\n rowRefs.current[i] = el;\n }}\n onMouseDown={(event) => event.preventDefault()}\n onMouseEnter={() => {\n setActive(i);\n setEngaged(true);\n }}\n onClick={() => pick(row)}\n className=\"relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[6px] px-2 text-left\"\n >\n {source && (\n <span className=\"flex size-5.5 shrink-0 items-center justify-center text-ink-2\">\n {source.brand ? BRANDS[source.brand] : <Icon size={15}>{GLYPHS[source.glyph ?? \"clip\"]}</Icon>}\n </span>\n )}\n <span className=\"shrink-0 text-[12.5px] font-medium text-ink\">\n {row.name}\n </span>\n <span className=\"min-w-0 flex-1 truncate text-[12px] text-ink-3\">{row.desc}</span>\n {source?.connect && (\n <span\n role=\"button\"\n tabIndex={-1}\n onClick={(event) => {\n event.stopPropagation();\n setConnected((current) => !current);\n }}\n className={`shrink-0 text-[12px] font-medium transition-colors duration-100 ${\n connected ? \"text-green\" : \"text-accent-ink hover:underline\"\n }`}\n >\n {connected ? \"Connected\" : \"Connect\"}\n </span>\n )}\n </button>\n );\n })}\n {rows.length === 0 && (\n <div className=\"flex h-9 items-center px-2 text-[12px] text-ink-3\">\n No matches for “{query}”\n </div>\n )}\n <div className=\"mt-1 border-t border-line px-2 pt-1.5 pb-1 text-[11px] text-ink-3\">\n {menu === \"at\" ? \"Type to search sources & files\" : \"Type to search commands\"}\n </div>\n </div>\n )}\n\n {/* ── model menu ─────────────────────────────────── */}\n {modelOpen && (\n <div\n onMouseLeave={() => setModelHovered(null)}\n className=\"absolute z-10 w-44 rounded-[10px] bg-surface p-1 shadow-raised\"\n style={{ left: modelMenuLeft, bottom: modelMenuBottom, animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\", transformOrigin: \"bottom left\" }}\n >\n {/* single gliding highlight — floats to the hovered / selected row */}\n <span\n aria-hidden\n className=\"pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover\"\n style={{\n top: modelBox?.top ?? 0,\n height: modelBox?.height ?? 0,\n opacity: modelBox && modelHovered !== null ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {MODELS.map((m, i) => (\n <button\n key={m.key}\n type=\"button\"\n ref={(el) => {\n modelRowRefs.current[i] = el;\n }}\n onMouseDown={(event) => event.preventDefault()}\n onMouseEnter={() => setModelHovered(i)}\n onClick={() => {\n selectModel(m);\n inputRef.current?.focus();\n }}\n className=\"relative z-10 flex h-7.5 w-full items-center gap-2 rounded-[6px] px-2 text-left\"\n >\n <span className=\"min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink\">{m.name}</span>\n <span className=\"shrink-0 text-[11px] text-ink-3\">{m.tag}</span>\n <span className={`shrink-0 text-ink ${m.key === model.key ? \"\" : \"invisible\"}`}>\n <Icon size={13} strokeWidth={2.5}><path d=\"M20 6L9 17l-5-5\" /></Icon>\n </span>\n </button>\n ))}\n </div>\n )}\n\n {/* ── composer ───────────────────────────────────── */}\n <div\n className={`relative isolate flex flex-col overflow-hidden border border-line bg-surface shadow-card transition-[border-color,border-radius] duration-150 focus-within:border-line-strong ${\n tall ? \"gap-2.5 p-3.5\" : \"gap-1.5 p-1.5\"\n } ${\n pill ? (attachments.length > 0 || wide ? \"rounded-[24px]\" : \"rounded-full\") : tall ? \"rounded-[22px]\" : \"rounded-[14px]\"\n }`}\n >\n {/* rainbow glimm sweep — plays across the interior on model change.\n explicit w/h: a <canvas> is a replaced element and won't stretch\n to inset-0 alone, which feeds back into the shader's ResizeObserver. */}\n <canvas\n ref={glimmRef}\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 -z-10 h-full w-full\"\n style={{ borderRadius: \"inherit\" }}\n />\n <span\n ref={measureRef}\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute invisible whitespace-pre text-[13px] leading-[18px]\"\n >\n {draft}\n </span>\n\n {attachments.length > 0 && (\n <div className={`flex flex-wrap gap-1.5 pt-0.5 ${pill ? \"px-1\" : \"px-0.5\"}`}>\n {attachments.map((file, i) => (\n <span\n key={`${file}-${i}`}\n className={`flex h-6.5 items-center gap-1.5 bg-field py-1 pr-1 pl-1.5 text-[11.5px] text-ink-2 shadow-hairline ${\n pill ? \"rounded-full\" : \"rounded-chip\"\n }`}\n style={{ animation: \"pop-in 200ms cubic-bezier(0.23,1,0.32,1) both\" }}\n >\n <Icon size={12}><g><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\" /><path d=\"M14 2v6h6\" /></g></Icon>\n <span className=\"max-w-36 truncate\">{file}</span>\n <button\n type=\"button\"\n aria-label={`Remove ${file}`}\n onClick={() => setAttachments((current) => current.filter((_, j) => j !== i))}\n className={`-my-1 flex size-6 items-center justify-center text-ink-3 transition-colors duration-100 hover:bg-line/70 hover:text-ink ${\n pill ? \"rounded-full\" : \"rounded-[5px]\"\n }`}\n >\n <Icon size={10} strokeWidth={2.5}><path d=\"M18 6L6 18M6 6l12 12\" /></Icon>\n </button>\n </span>\n ))}\n </div>\n )}\n\n <div\n ref={controlsRef}\n className={`grid items-end gap-x-1 gap-y-1.5 ${\n wide\n ? \"grid-cols-[28px_auto_minmax(0,1fr)_28px_28px]\"\n : \"grid-cols-[28px_minmax(0,1fr)_auto_28px_28px]\"\n }`}\n >\n <button\n type=\"button\"\n aria-label=\"Add attachments and sources\"\n aria-expanded={plusOpen}\n onClick={() => {\n setModelOpen(false);\n setPlusOpen((current) => !current);\n inputRef.current?.focus();\n }}\n className={`flex size-7 shrink-0 items-center justify-center justify-self-start text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] ${\n pill ? \"rounded-full\" : \"rounded-[8px]\"\n } ${plusOpen ? \"bg-hover text-ink\" : \"\"} ${wide ? \"col-start-1 row-start-2\" : \"col-start-1 row-start-1\"}`}\n >\n <Icon size={16} strokeWidth={2}><path d=\"M12 5v14M5 12h14\" /></Icon>\n </button>\n\n <textarea\n ref={inputRef}\n rows={1}\n value={draft}\n onChange={(event) => {\n setDraft(event.target.value);\n setDismissed(false);\n setPlusOpen(false);\n }}\n onKeyDown={(event) => {\n if (menu && rows.length > 0) {\n if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n event.preventDefault();\n setEngaged(true);\n setActive((current) => (current + (event.key === \"ArrowDown\" ? 1 : rows.length - 1)) % rows.length);\n return;\n }\n if ((event.key === \"Enter\" && !event.shiftKey) || event.key === \"Tab\") {\n event.preventDefault();\n pick(rows[active]);\n return;\n }\n }\n if (event.key === \"Escape\") {\n setDismissed(true);\n closeMenus();\n return;\n }\n if (event.key === \"Enter\" && !event.shiftKey && !event.nativeEvent.isComposing) {\n event.preventDefault();\n send();\n }\n }}\n placeholder={listening ? \"Listening…\" : placeholder ?? \"Write a message…\"}\n aria-label=\"Prompt\"\n className={`${tall ? \"min-h-[68px] px-2 py-2 text-[14px] leading-5\" : \"min-h-7 px-1 py-[5px] text-[13px] leading-[18px]\"} min-w-0 w-full resize-none bg-transparent text-ink outline-none [overflow-wrap:anywhere] placeholder:text-ink-3 ${\n wide ? \"col-span-full col-start-1 row-start-1\" : \"col-start-2 row-start-1\"\n }`}\n />\n\n {/* model picker */}\n <button\n ref={modelRef}\n type=\"button\"\n aria-expanded={modelOpen}\n aria-label=\"Choose model\"\n onClick={() => {\n setPlusOpen(false);\n setModelOpen((current) => !current);\n }}\n className={`flex h-7 shrink-0 items-center gap-1 px-1.5 text-[12px] font-medium text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink ${\n pill ? \"rounded-full\" : \"rounded-[8px]\"\n } ${wide ? \"col-start-2 row-start-2 justify-self-start\" : \"col-start-3 row-start-1\"}`}\n >\n {model.name}\n <span className=\"text-ink-3\">\n <Icon size={11} strokeWidth={2.4}><path d=\"M6 9l6 6 6-6\" /></Icon>\n </span>\n </button>\n\n {/* dictation */}\n <button\n type=\"button\"\n aria-label={listening ? \"Stop dictation\" : \"Start dictation\"}\n aria-pressed={listening}\n onClick={() => setListening((current) => !current)}\n className={`flex size-7 shrink-0 items-center justify-center transition-[background-color,color,transform] duration-150 active:scale-[0.94] ${\n pill ? \"rounded-full\" : \"rounded-[8px]\"\n } ${listening ? \"bg-accent-tint text-accent-ink\" : \"text-ink-3 hover:bg-hover hover:text-ink\"} ${wide ? \"col-start-4 row-start-2\" : \"col-start-4 row-start-1\"}`}\n >\n {listening ? (\n <span className=\"flex h-3.5 items-center gap-[2.5px]\">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className=\"w-[2.5px] rounded-full bg-current\"\n style={{ height: \"100%\", animation: `eq-bounce 900ms ease-in-out ${i * 150}ms infinite` }}\n />\n ))}\n </span>\n ) : (\n <Icon size={15} strokeWidth={2}><g><path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z\" /><path d=\"M19 10v2a7 7 0 0 1-14 0v-2M12 19v3\" /></g></Icon>\n )}\n </button>\n\n {/* send — tactile square (round in the pill variant) */}\n <button\n type=\"button\"\n aria-label=\"Send\"\n disabled={!canSend}\n onClick={send}\n className={`flex size-7 shrink-0 items-center justify-center transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] ${\n pill ? \"rounded-full\" : \"rounded-[8px]\"\n } ${wide ? \"col-start-5 row-start-2\" : \"col-start-5 row-start-1\"}`}\n style={{\n background: canSend ? \"var(--ink)\" : \"var(--line-strong)\",\n color: canSend ? \"var(--surface)\" : \"var(--ink-2)\",\n }}\n >\n <Icon size={16} strokeWidth={2.4}><path d=\"M12 19V5M5 12l7-7 7 7\" /></Icon>\n </button>\n </div>\n </div>\n </div>\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/PromptBar.tsx"
}
]
}