Files
inkreach-agent/docs/ui-ref/beautifului/chat-composer.json
T

18 lines
9.5 KiB
JSON

{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "chat-composer",
"type": "registry:component",
"title": "Chat",
"description": "Tabbed chat panel with reasoning replies and a composer.",
"registryDependencies": [
"https://www.beautifului.dev/r/foundation.json"
],
"files": [
{
"path": "components/primitives/ChatComposer.tsx",
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * CHAT — interactive panel with tabs, replies, and composer.\n * The reply sequence begins only after the user sends.\n * ───────────────────────────────────────────────────────── */\n\ntype Phase = \"idle\" | \"sent\" | \"reply1\" | \"reply2\" | \"done\";\n\n/* one scripted agent reply in the thread */\nexport type ChatMessage = {\n label: string;\n sub: string;\n time: string;\n body: string;\n};\n\nconst MESSAGES: ChatMessage[] = [\n {\n label: \"Sales History\",\n sub: \"Flavor Data\",\n time: \"4s\",\n body: \"Pulled 3 summers of mint chip sales for comparison.\",\n },\n {\n label: \"Comparison\",\n sub: \"Trend Detection\",\n time: \"2s\",\n body: \"Mint chip is up 12% with stronger weekend peaks.\",\n },\n];\n\nconst SUGGESTIONS = [\"Flavors\", \"Suppliers\"];\n\nexport type ChatComposerLabels = {\n /** the pre-filled prompt shown in the first user bubble */\n initialPrompt: string;\n /** composer input placeholder */\n placeholder: string;\n};\n\nconst DEFAULT_LABELS: ChatComposerLabels = {\n initialPrompt: \"Compare mint chip to last summer\",\n placeholder: \"Prompt or tag a flavor with @\",\n};\n\nfunction Section({\n label,\n sub,\n time,\n body,\n resolving,\n}: {\n label: string;\n sub: string;\n time: string;\n body: string;\n resolving?: boolean;\n}) {\n return (\n <div\n className=\"flex w-full flex-col gap-1.5 transition-[opacity,filter,transform] duration-400\"\n style={{\n opacity: resolving ? 0.55 : 1,\n filter: resolving ? \"blur(0.5px)\" : \"blur(0)\",\n transform: resolving ? \"scale(0.985)\" : \"scale(1)\",\n transformOrigin: \"top left\",\n transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n animation: \"fade-up 400ms cubic-bezier(0.23,1,0.32,1) both\",\n }}\n >\n <div className=\"flex items-center gap-1 text-[12px] leading-[1.3]\">\n <span className=\"font-medium text-ink\">{label}</span>\n <span className=\"text-ink-2\">{sub}</span>\n <span className=\"text-ink\">for {time}</span>\n </div>\n <p className=\"text-[13px] leading-normal text-ink\">{body}</p>\n </div>\n );\n}\n\nexport default function ChatComposer({\n messages = MESSAGES,\n suggestions = SUGGESTIONS,\n labels,\n onSend,\n}: {\n variant?: string;\n /** scripted agent replies revealed in sequence after the user sends */\n messages?: ChatMessage[];\n /** header chips (tabs) for switching context */\n suggestions?: string[];\n /** prominent copy strings */\n labels?: Partial<ChatComposerLabels>;\n /** fired with the trimmed prompt text when the user sends */\n onSend?: (text: string) => void;\n} = {}) {\n const l = { ...DEFAULT_LABELS, ...labels };\n const [phase, setPhase] = useState<Phase>(\"done\");\n const [draft, setDraft] = useState(\"\");\n const [submitted, setSubmitted] = useState(l.initialPrompt);\n const [tab, setTab] = useState(suggestions[0] ?? \"\");\n const inputRef = useRef<HTMLInputElement>(null);\n\n useEffect(() => {\n let t: ReturnType<typeof setTimeout>;\n if (phase === \"sent\") t = setTimeout(() => setPhase(\"reply1\"), 500);\n else if (phase === \"reply1\") t = setTimeout(() => setPhase(\"reply2\"), 1400);\n else if (phase === \"reply2\") t = setTimeout(() => setPhase(\"done\"), 1200);\n else return;\n return () => clearTimeout(t);\n }, [phase]);\n\n const sent = phase !== \"idle\";\n const canSend = draft.trim().length > 0;\n\n const send = () => {\n if (!canSend) return;\n const text = draft.trim();\n setSubmitted(text);\n onSend?.(text);\n setDraft(\"\");\n setPhase(\"sent\");\n };\n\n return (\n <div className=\"flex h-[288px] w-full max-w-95 flex-col self-start overflow-hidden rounded-[14px] bg-surface shadow-card\">\n {/* header — tabs + actions */}\n <div className=\"flex shrink-0 items-center justify-between border-b border-line p-1.5\">\n <div className=\"flex items-center\">\n {suggestions.map((item) => (\n <button\n key={item}\n type=\"button\"\n aria-pressed={tab === item}\n onClick={() => setTab(item)}\n className={`rounded-[6px] px-2 py-[3px] text-[13px] text-ink transition-[background-color,opacity] duration-100 ${tab === item ? \"bg-field\" : \"opacity-50 hover:opacity-75\"}`}\n >\n {item}\n </button>\n ))}\n </div>\n <div className=\"flex items-center gap-1\">\n {[\n <path key=\"p\" d=\"M12 5v14M5 12h14\" />,\n <g key=\"h\"><circle cx=\"12\" cy=\"12\" r=\"9\" /><path d=\"M12 7v5l3 2\" /></g>,\n <g key=\"e\" fill=\"currentColor\" stroke=\"none\"><circle cx=\"5\" cy=\"12\" r=\"1.8\" /><circle cx=\"12\" cy=\"12\" r=\"1.8\" /><circle cx=\"19\" cy=\"12\" r=\"1.8\" /></g>,\n ].map((icon, i) => (\n <button\n key={i}\n type=\"button\"\n aria-label=\"Action\"\n className=\"flex size-6 items-center justify-center rounded-[6px] text-ink-3\n transition-colors duration-100 hover:bg-hover hover:text-ink-2\"\n >\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n {icon}\n </svg>\n </button>\n ))}\n </div>\n </div>\n\n {/* conversation — fixed region so the card never changes shape */}\n <div className=\"flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-3 pt-2.5 pb-1\">\n {/* user bubble — right aligned, soft block */}\n <div className=\"flex justify-end pl-14\">\n <div\n className=\"rounded-xl bg-field px-3 py-1.5 text-[13px] leading-[1.4] text-ink\n transition-[opacity,transform] duration-300\"\n style={{\n opacity: sent ? 1 : 0,\n transform: sent ? \"translateY(0)\" : \"translateY(10px)\",\n transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n }}\n >\n {submitted}\n </div>\n </div>\n\n {messages[0] && (phase === \"reply1\" || phase === \"reply2\" || phase === \"done\") ? (\n <Section\n label={messages[0].label}\n sub={messages[0].sub}\n time={messages[0].time}\n body={messages[0].body}\n />\n ) : null}\n {messages[1] && (phase === \"reply2\" || phase === \"done\") ? (\n <Section\n label={messages[1].label}\n sub={messages[1].sub}\n time={messages[1].time}\n body={messages[1].body}\n resolving={phase === \"reply2\"}\n />\n ) : null}\n </div>\n\n {/* composer */}\n <div className=\"mt-auto shrink-0 p-1.5\">\n <div\n role=\"presentation\"\n onClick={() => inputRef.current?.focus()}\n className=\"flex cursor-text flex-col gap-2 rounded-control border border-line bg-field p-2.5 shadow-[0_1px_2px_rgba(0,0,0,0.035)] transition-[border-color,box-shadow] duration-150 focus-within:border-line-strong focus-within:shadow-[0_1px_2px_rgba(0,0,0,0.025)]\"\n >\n <input\n ref={inputRef}\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") send();\n }}\n placeholder={l.placeholder}\n aria-label=\"Chat prompt\"\n className=\"min-h-4.5 bg-transparent text-[13px] leading-[1.4] text-ink outline-none placeholder:text-ink-3\"\n />\n <div className=\"flex items-center justify-end\">\n <button\n type=\"button\"\n aria-label=\"Send\"\n disabled={!canSend}\n onClick={send}\n className=\"flex size-7 items-center justify-center rounded-[8px]\n transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.96]\"\n style={{\n background: canSend ? \"var(--ink)\" : \"var(--line-strong)\",\n color: canSend ? \"var(--surface)\" : \"var(--ink-2)\",\n }}\n >\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d=\"M12 19V5M5 12l7-7 7 7\" />\n </svg>\n </button>\n </div>\n </div>\n </div>\n </div>\n );\n}\n",
"type": "registry:component",
"target": "components/primitives/ChatComposer.tsx"
}
]
}