{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"Full ChatGPT-style chat surface — sidebar, transcript and composer — wired to a simulated agent so every AI component is on screen.","devDependencies":[],"files":[{"content":"import type { AIApprovalOption } from \"@/components/smoothui/ai-approval\";\nimport type { AIDiffLine } from \"@/components/smoothui/ai-diff\";\nimport type { AIPromptAttachment } from \"@/components/smoothui/ai-prompt-input\";\nimport type { AIResponseCitation } from \"@/components/smoothui/ai-response\";\nimport type { AISource } from \"@/components/smoothui/ai-sources\";\nimport type { AISuggestion } from \"@/components/smoothui/ai-suggestions\";\nimport type { AITask } from \"@/components/smoothui/ai-task-list\";\n\n/**\n * A transcript turn.\n *\n * Assistant turns are a bag of optional parts rather than a block of markdown,\n * because that is what an agent turn actually is: some thinking, some tool\n * calls, some produced artifact, then prose. Adding a new part means adding one\n * field here and one branch in `chat-thread.tsx` — that is the extension point\n * as more of the AI set lands.\n */\nexport type ChatTurn =\n  | {\n      /** Files that rode along with the message. */\n      attachments?: AIPromptAttachment[];\n      from: \"user\";\n      id: string;\n      text: string;\n      timestamp: string;\n    }\n  | {\n      approval?: { options: AIApprovalOption[]; question: string };\n      artifact?: { code: string; language: string; title: string };\n      citations?: AIResponseCitation[];\n      diff?: { lines: AIDiffLine[]; title: string };\n      from: \"assistant\";\n      id: string;\n      reasoning?: string;\n      sources?: AISource[];\n      suggestions?: AISuggestion[];\n      tasks?: { label: string; tasks: AITask[] };\n      text?: string;\n      timestamp: string;\n      tool?: { args: string; name: string; result: string; summary: string };\n    };\n\nexport type ChatConversation = {\n  /** Sidebar grouping label — real products group by recency, so this does. */\n  group: string;\n  id: string;\n  title: string;\n  turns: ChatTurn[];\n};\n\nconst RETIRE_FLAVOUR: ChatTurn[] = [\n  {\n    attachments: [\n      { id: \"a1\", name: \"sales-velocity-2026-q2.csv\", size: 24_600 },\n      { id: \"a2\", name: \"flavour-review.pdf\", size: 1_248_000 },\n    ],\n    from: \"user\",\n    id: \"r1\",\n    text: \"Compare mint chip to last summer and tell me which flavour to retire before Q4.\",\n    timestamp: \"14:31\",\n  },\n  {\n    citations: [\n      { id: \"1\", index: 1, title: \"Sales velocity export, 2026 Q2\" },\n      { id: \"2\", index: 2, title: \"Flavour performance review\" },\n    ],\n    from: \"assistant\",\n    id: \"r2\",\n    reasoning:\n      \"Pulled three summers of sales, normalised for the two stores that opened last year, then compared weekend and weekday velocity before ranking the classics.\",\n    sources: [\n      {\n        id: \"s1\",\n        snippet: \"Weekly units by flavour and store, 2024–2026.\",\n        title: \"Sales velocity export, 2026 Q2\",\n        url: \"internal://warehouse/sales-velocity-2026-q2.csv\",\n      },\n      {\n        id: \"s2\",\n        snippet: \"Margin per scoop after the dairy contract renewal.\",\n        title: \"Flavour performance review\",\n        url: \"internal://docs/flavour-performance-review.md\",\n      },\n    ],\n    suggestions: [\n      { id: \"g1\", label: \"Draft the retirement announcement\" },\n      { id: \"g2\", label: \"What replaces rocky road?\" },\n      { id: \"g3\", label: \"Show the margin per scoop\" },\n    ],\n    text: \"Mint chip is up 12% on last summer, and the whole gain sits on weekends [1]. Rocky road is the one to retire — down 6% year on year, lowest margin per scoop of the classics, and it does not recover in any store [2].\",\n    timestamp: \"14:32\",\n    tool: {\n      args: '{ \"flavours\": [\"mint-chip\", \"rocky-road\"], \"years\": 3 }',\n      name: \"query_sales\",\n      result: \"412 rows\",\n      summary: \"3 summers\",\n    },\n  },\n];\n\nconst SHIP_PRICE_CHANGE: ChatTurn[] = [\n  {\n    from: \"user\",\n    id: \"p1\",\n    text: \"Raise the single scoop to 4.20 everywhere and stage the change.\",\n    timestamp: \"11:04\",\n  },\n  {\n    approval: {\n      options: [\n        { id: \"ship\", label: \"Apply to all 14 stores\" },\n        {\n          detail: \"Rolls out to the two pilot stores only\",\n          id: \"pilot\",\n          label: \"Pilot first\",\n        },\n        { destructive: true, id: \"discard\", label: \"Discard the change\" },\n      ],\n      question: \"This updates live prices in 14 stores. Apply now?\",\n    },\n    diff: {\n      lines: [\n        { content: \"  scoops:\", kind: \"context\", number: 12 },\n        { content: \"-   single: 3.90\", kind: \"removed\", number: 13 },\n        { content: \"+   single: 4.20\", kind: \"added\", number: 13 },\n        { content: \"    double: 6.40\", kind: \"context\", number: 14 },\n        { content: \"-   kids: 2.60\", kind: \"removed\", number: 15 },\n        { content: \"+   kids: 2.80\", kind: \"added\", number: 15 },\n      ],\n      title: \"config/pricing.yaml\",\n    },\n    from: \"assistant\",\n    id: \"p2\",\n    tasks: {\n      label: \"Plan\",\n      tasks: [\n        { id: \"t1\", label: \"Read current price table\", status: \"done\" },\n        {\n          children: [\n            { id: \"t2a\", label: \"Update scoop tiers\", status: \"done\" },\n            { id: \"t2b\", label: \"Update kids portion\", status: \"done\" },\n          ],\n          id: \"t2\",\n          label: \"Stage the new prices\",\n          note: \"2 files\",\n          status: \"done\",\n        },\n        { id: \"t3\", label: \"Wait for approval\", status: \"running\" },\n        {\n          id: \"t4\",\n          label: \"Publish to stores\",\n          note: \"14 stores\",\n          status: \"pending\",\n        },\n      ],\n    },\n    text: \"Staged. Kids portion moves with it to keep the ratio you set last spring — say the word and I will publish.\",\n    timestamp: \"11:05\",\n  },\n];\n\nconst SUMMER_CAMPAIGN: ChatTurn[] = [\n  {\n    from: \"user\",\n    id: \"c1\",\n    text: \"Give me a summer campaign banner I can drop into the site.\",\n    timestamp: \"09:12\",\n  },\n  {\n    artifact: {\n      code: `export const SummerBanner = () => (\n  <section className=\"rounded-3xl bg-gradient-to-br from-pink-200 to-sky-200 p-10\">\n    <p className=\"text-sm uppercase tracking-widest\">Summer 2026</p>\n    <h2 className=\"mt-2 text-4xl font-semibold\">Two scoops, one price</h2>\n    <p className=\"mt-3 max-w-sm text-sm\">\n      Every weekday before 5pm, all summer.\n    </p>\n  </section>\n);`,\n      language: \"tsx\",\n      title: \"SummerBanner.tsx\",\n    },\n    from: \"assistant\",\n    id: \"c2\",\n    suggestions: [\n      { id: \"c-s1\", label: \"Make it dark mode aware\" },\n      { id: \"c-s2\", label: \"Add a countdown\" },\n    ],\n    text: \"Here it is, using the pink and sky pair from your brand tokens rather than new colours.\",\n    timestamp: \"09:13\",\n  },\n];\n\nexport const CONVERSATIONS: ChatConversation[] = [\n  {\n    group: \"Today\",\n    id: \"retire-flavour\",\n    title: \"Which flavour to retire\",\n    turns: RETIRE_FLAVOUR,\n  },\n  {\n    group: \"Today\",\n    id: \"price-change\",\n    title: \"Stage the price change\",\n    turns: SHIP_PRICE_CHANGE,\n  },\n  {\n    group: \"Previous 7 days\",\n    id: \"summer-campaign\",\n    title: \"Summer campaign banner\",\n    turns: SUMMER_CAMPAIGN,\n  },\n];\n\n/** Follow-ups offered on an empty thread, so the composer is never a blank stare. */\nexport const STARTER_SUGGESTIONS: AISuggestion[] = [\n  { id: \"st1\", label: \"Which store is growing fastest?\" },\n  { id: \"st2\", label: \"Draft next week's staff rota\" },\n  { id: \"st3\", label: \"Summarise last month's reviews\" },\n];\n\n/**\n * The reply the template plays back for anything you type.\n *\n * There is no model behind this. Everything below is a fixed script, which is\n * the honest way to demo a chat surface: the components are the product, the\n * answer is set dressing.\n */\nexport const SIMULATED_REPLY = {\n  citations: [\n    { id: \"1\", index: 1, title: \"Store operations log, week 30\" },\n  ] satisfies AIResponseCitation[],\n  reasoning:\n    \"Checked the four stores that reported this week, then compared footfall against the same week last year before answering.\",\n  sources: [\n    {\n      id: \"sim-s1\",\n      snippet: \"Hourly footfall and till receipts, week 30.\",\n      title: \"Store operations log, week 30\",\n      url: \"internal://warehouse/ops-log-w30.csv\",\n    },\n  ] satisfies AISource[],\n  suggestions: [\n    { id: \"sim-g1\", label: \"Break it down by store\" },\n    { id: \"sim-g2\", label: \"Compare with last summer\" },\n  ] satisfies AISuggestion[],\n  text: \"Weekday afternoons are the soft spot: footfall holds but the average ticket drops about 18% after 3pm [1]. A two-scoop weekday offer is the cheapest lever you have before Q4.\",\n  tool: {\n    args: '{ \"week\": 30, \"stores\": \"all\" }',\n    name: \"query_operations\",\n    result: \"96 rows\",\n    summary: \"week 30\",\n  },\n} as const;\n\n/** Offered by the composer's attach control — a picker stands in for a file dialog. */\nexport const ATTACHABLE_FILES: AIPromptAttachment[] = [\n  { id: \"f1\", name: \"ops-log-w30.csv\", size: 18_400 },\n  { id: \"f2\", name: \"staff-rota-august.xlsx\", size: 42_900 },\n  { id: \"f3\", name: \"supplier-invoice-3312.pdf\", size: 268_000 },\n];\n\nexport const MODELS = [\n  { id: \"opus-5\", label: \"Opus 5\", note: \"Best for planning\" },\n  { id: \"sonnet-5\", label: \"Sonnet 5\", note: \"Fast, everyday work\" },\n  { id: \"haiku-4-5\", label: \"Haiku 4.5\", note: \"Cheapest\" },\n] as const;\n\nexport const CONTEXT_LIMIT = 200_000;\n","path":"chat-data.ts","target":"components/smoothui/chat-template/chat-data.ts","type":"registry:block"},{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SiriOrb from \"@/components/smoothui/siri-orb\";\nimport {\n  LogOut,\n  Moon,\n  PanelLeftClose,\n  PanelLeftOpen,\n  Plus,\n  Search,\n  Settings,\n  Sun,\n} from \"lucide-react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport type { ChatConversation } from \"./chat-data\";\n\n/** A real photograph, so the footer is a person and not a lettered circle. */\nconst USER_AVATAR =\n  \"https://ik.imagekit.io/16u211libb/avatar-educalvolpz.jpeg?tr=w-64,h-64\";\n\nexport type ChatSidebarProps = {\n  activeId: string;\n  className?: string;\n  /** Icon rail instead of the full pane. Keeps navigation reachable. */\n  collapsed?: boolean;\n  conversations: ChatConversation[];\n  onNewChat: () => void;\n  onSelect: (id: string) => void;\n  onToggleCollapsed?: () => void;\n};\n\nexport const ChatSidebar = ({\n  activeId,\n  className,\n  collapsed = false,\n  conversations,\n  onNewChat,\n  onSelect,\n  onToggleCollapsed,\n}: ChatSidebarProps) => {\n  const [query, setQuery] = useState(\"\");\n  const searchRef = useRef<HTMLInputElement>(null);\n\n  // The search filters for real. A search box that does nothing is the kind of\n  // fake content this template exists to avoid.\n  const groups = useMemo(() => {\n    const needle = query.trim().toLowerCase();\n    const matching = needle\n      ? conversations.filter((conversation) =>\n          conversation.title.toLowerCase().includes(needle)\n        )\n      : conversations;\n\n    const byGroup = new Map<string, ChatConversation[]>();\n    for (const conversation of matching) {\n      const bucket = byGroup.get(conversation.group) ?? [];\n      bucket.push(conversation);\n      byGroup.set(conversation.group, bucket);\n    }\n    return [...byGroup.entries()];\n  }, [conversations, query]);\n\n  if (collapsed) {\n    return (\n      <aside\n        className={cn(\n          \"flex h-full w-[4.5rem] shrink-0 flex-col items-center gap-2 border-border/60 border-r bg-muted/60 py-3\",\n          className\n        )}\n      >\n        <SiriOrb size=\"22px\" state=\"idle\" />\n        <RailButton\n          icon={<PanelLeftOpen aria-hidden=\"true\" size={16} />}\n          label=\"Expand sidebar\"\n          onClick={onToggleCollapsed}\n        />\n        <RailButton\n          icon={<Plus aria-hidden=\"true\" size={16} />}\n          label=\"New chat\"\n          onClick={onNewChat}\n        />\n        <RailButton\n          icon={<Search aria-hidden=\"true\" size={16} />}\n          label=\"Search chats\"\n          onClick={() => {\n            onToggleCollapsed?.();\n            // Expanding and focusing in one action, so the icon is a shortcut\n            // rather than a two-step detour.\n            requestAnimationFrame(() => searchRef.current?.focus());\n          }}\n        />\n        <div className=\"mt-auto\">\n          <AccountMenu align=\"rail\" />\n        </div>\n      </aside>\n    );\n  }\n\n  return (\n    <aside\n      className={cn(\n        \"flex h-full w-[17rem] shrink-0 flex-col gap-3 border-border/60 border-r bg-muted/60 p-3\",\n        className\n      )}\n    >\n      <div className=\"flex items-center justify-between gap-2 px-1\">\n        <span className=\"flex items-center gap-2 font-medium text-sm\">\n          <SiriOrb size=\"22px\" state=\"idle\" />\n          Scoop Assistant\n        </span>\n        <button\n          aria-label=\"Collapse sidebar\"\n          className=\"rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n          onClick={onToggleCollapsed}\n          type=\"button\"\n        >\n          <PanelLeftClose aria-hidden=\"true\" size={16} />\n        </button>\n      </div>\n\n      <button\n        className=\"flex items-center gap-2 rounded-xl border border-border/60 bg-background px-3 py-2 text-left text-sm transition-colors hover:bg-muted\"\n        onClick={onNewChat}\n        type=\"button\"\n      >\n        <Plus aria-hidden=\"true\" size={15} />\n        New chat\n      </button>\n\n      {/* Icon and field share one flex row rather than absolute-positioning the\n          icon over padding: the gap is then a single value instead of two that\n          have to be kept in sync, and `type=\"search\"` cannot push the text away\n          with its own intrinsic padding. */}\n      <label className=\"flex items-center gap-2 rounded-xl border border-transparent bg-muted px-2.5 py-2 transition-colors focus-within:border-border focus-within:bg-background\">\n        <span className=\"sr-only\">Search chats</span>\n        <Search\n          aria-hidden=\"true\"\n          className=\"shrink-0 text-muted-foreground\"\n          size={14}\n        />\n        <input\n          className=\"w-full appearance-none bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n          onChange={(event) => setQuery(event.target.value)}\n          placeholder=\"Search chats\"\n          ref={searchRef}\n          type=\"search\"\n          value={query}\n        />\n      </label>\n\n      <nav className=\"-mx-1 flex-1 overflow-y-auto px-1\">\n        {groups.length === 0 && (\n          <p className=\"px-2 py-6 text-center text-muted-foreground text-xs\">\n            No chats match “{query}”.\n          </p>\n        )}\n\n        {groups.map(([group, items]) => (\n          <div className=\"mb-3\" key={group}>\n            <p className=\"px-2 pt-1 pb-1.5 font-medium text-[0.7rem] text-muted-foreground/80 uppercase tracking-wide\">\n              {group}\n            </p>\n            <ul className=\"flex flex-col gap-0.5\">\n              {items.map((conversation) => (\n                <li key={conversation.id}>\n                  <button\n                    aria-current={\n                      conversation.id === activeId ? \"page\" : undefined\n                    }\n                    className={cn(\n                      \"w-full truncate rounded-lg px-2 py-2 text-left text-sm transition-colors\",\n                      conversation.id === activeId\n                        ? \"bg-background text-foreground shadow-black/5 shadow-xs\"\n                        : \"text-muted-foreground hover:bg-muted hover:text-foreground\"\n                    )}\n                    onClick={() => onSelect(conversation.id)}\n                    type=\"button\"\n                  >\n                    {conversation.title}\n                  </button>\n                </li>\n              ))}\n            </ul>\n          </div>\n        ))}\n      </nav>\n\n      <div className=\"border-border/60 border-t pt-3\">\n        <AccountMenu align=\"pane\" />\n      </div>\n    </aside>\n  );\n};\n\nconst RailButton = ({\n  icon,\n  label,\n  onClick,\n}: {\n  icon: React.ReactNode;\n  label: string;\n  onClick?: () => void;\n}) => (\n  <button\n    aria-label={label}\n    className=\"rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n    onClick={onClick}\n    title={label}\n    type=\"button\"\n  >\n    {icon}\n  </button>\n);\n\n/**\n * The account menu, with the theme switch inside it.\n *\n * It flips the `dark` class on the document root rather than shipping a theme\n * provider: that is the one contract every Tailwind setup already has, so the\n * control works the moment the template is installed instead of after wiring.\n */\nconst AccountMenu = ({ align }: { align: \"pane\" | \"rail\" }) => {\n  const [isOpen, setIsOpen] = useState(false);\n  const [isDark, setIsDark] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    setIsDark(document.documentElement.classList.contains(\"dark\"));\n  }, []);\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (!containerRef.current?.contains(event.target as Node)) {\n        setIsOpen(false);\n      }\n    };\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setIsOpen(false);\n      }\n    };\n\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown);\n      document.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [isOpen]);\n\n  const toggleTheme = () => {\n    const next = !document.documentElement.classList.contains(\"dark\");\n    document.documentElement.classList.toggle(\"dark\", next);\n    setIsDark(next);\n  };\n\n  return (\n    <div className=\"relative\" ref={containerRef}>\n      <button\n        aria-expanded={isOpen}\n        aria-haspopup=\"menu\"\n        className={cn(\n          \"flex w-full items-center gap-2 rounded-xl text-left transition-colors hover:bg-muted\",\n          align === \"pane\" ? \"p-1.5\" : \"justify-center p-1\"\n        )}\n        onClick={() => setIsOpen((open) => !open)}\n        type=\"button\"\n      >\n        <img\n          alt=\"Edu Calvo\"\n          className=\"size-7 shrink-0 rounded-full object-cover\"\n          height={28}\n          src={USER_AVATAR}\n          width={28}\n        />\n        {align === \"pane\" && (\n          <span className=\"min-w-0 flex-1\">\n            <span className=\"block truncate text-sm\">Edu Calvo</span>\n            <span className=\"block text-muted-foreground text-xs\">\n              Pro plan\n            </span>\n          </span>\n        )}\n      </button>\n\n      {isOpen ? (\n        <div\n          className=\"absolute bottom-full left-0 z-10 mb-2 w-52 origin-bottom-left overflow-hidden rounded-xl border border-border/60 bg-background p-1 shadow-black/10 shadow-lg\"\n          role=\"menu\"\n        >\n          <MenuItem\n            icon={\n              isDark ? (\n                <Sun aria-hidden=\"true\" size={14} />\n              ) : (\n                <Moon aria-hidden=\"true\" size={14} />\n              )\n            }\n            label={isDark ? \"Light mode\" : \"Dark mode\"}\n            onClick={toggleTheme}\n          />\n          <MenuItem\n            icon={<Settings aria-hidden=\"true\" size={14} />}\n            label=\"Settings\"\n          />\n          <MenuItem\n            icon={<LogOut aria-hidden=\"true\" size={14} />}\n            label=\"Sign out\"\n          />\n        </div>\n      ) : null}\n    </div>\n  );\n};\n\nconst MenuItem = ({\n  icon,\n  label,\n  onClick,\n}: {\n  icon: React.ReactNode;\n  label: string;\n  onClick?: () => void;\n}) => (\n  <button\n    className=\"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground\"\n    onClick={onClick}\n    role=\"menuitem\"\n    type=\"button\"\n  >\n    {icon}\n    {label}\n  </button>\n);\n\nexport default ChatSidebar;\n","path":"chat-sidebar.tsx","target":"components/smoothui/chat-template/chat-sidebar.tsx","type":"registry:block"},{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport AIApproval from \"@/components/smoothui/ai-approval\";\nimport AIArtifact from \"@/components/smoothui/ai-artifact\";\nimport AIContextMeter from \"@/components/smoothui/ai-context-meter\";\nimport AIConversation from \"@/components/smoothui/ai-conversation\";\nimport type { AIState } from \"@/components/smoothui/ai-core\";\nimport AIDiff from \"@/components/smoothui/ai-diff\";\nimport AILoader from \"@/components/smoothui/ai-loader\";\nimport AIMessage from \"@/components/smoothui/ai-message\";\nimport AIPromptInput, {\n  type AIPromptAttachment,\n} from \"@/components/smoothui/ai-prompt-input\";\nimport AIReasoning from \"@/components/smoothui/ai-reasoning\";\nimport AIResponse from \"@/components/smoothui/ai-response\";\nimport AISources from \"@/components/smoothui/ai-sources\";\nimport AISuggestions from \"@/components/smoothui/ai-suggestions\";\nimport AITaskList from \"@/components/smoothui/ai-task-list\";\nimport AIToolCall from \"@/components/smoothui/ai-tool-call\";\nimport SiriOrb from \"@/components/smoothui/siri-orb\";\nimport { ChevronDown, PanelLeftOpen, Paperclip } from \"lucide-react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  ATTACHABLE_FILES,\n  type ChatTurn,\n  CONTEXT_LIMIT,\n  MODELS,\n  SIMULATED_REPLY,\n  STARTER_SUGGESTIONS,\n} from \"./chat-data\";\n\nconst THINK_MS = 900;\nconst TOOL_MS = 1900;\nconst STREAM_INTERVAL_MS = 45;\n/** Rough English ratio, good enough for a meter that is showing pressure. */\nconst CHARS_PER_TOKEN = 4;\nconst SYSTEM_TOKENS = 1800;\nconst TOKENS_PER_SOURCE = 9200;\nconst TOKENS_PER_ATTACHMENT = 6400;\n\ntype LivePhase = \"idle\" | \"thinking\" | \"tool\" | \"streaming\";\n\nconst formatClock = (date: Date) =>\n  `${String(date.getHours()).padStart(2, \"0\")}:${String(date.getMinutes()).padStart(2, \"0\")}`;\n\nexport type ChatThreadProps = {\n  className?: string;\n  /** Opens the conversation list on narrow screens, where it has no column. */\n  onOpenSidebar?: () => void;\n  title: string;\n  turns: ChatTurn[];\n};\n\nexport const ChatThread = ({\n  className,\n  onOpenSidebar,\n  title,\n  turns,\n}: ChatThreadProps) => {\n  const [localTurns, setLocalTurns] = useState<ChatTurn[]>([]);\n  const [phase, setPhase] = useState<LivePhase>(\"idle\");\n  const [streamed, setStreamed] = useState(\"\");\n  const [draftAttachments, setDraftAttachments] = useState<\n    AIPromptAttachment[]\n  >([]);\n  const [model, setModel] = useState<string>(MODELS[0].label);\n  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n\n  const clearTimers = useCallback(() => {\n    for (const timer of timers.current) {\n      clearTimeout(timer);\n    }\n    timers.current = [];\n  }, []);\n\n  useEffect(() => clearTimers, [clearTimers]);\n\n  useEffect(() => {\n    if (phase !== \"streaming\") {\n      return;\n    }\n    const words = SIMULATED_REPLY.text.split(\" \");\n    let index = 0;\n    const interval = setInterval(() => {\n      index += 1;\n      setStreamed(words.slice(0, index).join(\" \"));\n      if (index >= words.length) {\n        clearInterval(interval);\n        setPhase(\"idle\");\n        setLocalTurns((current) => [\n          ...current,\n          {\n            citations: [...SIMULATED_REPLY.citations],\n            from: \"assistant\",\n            id: `live-a-${current.length}`,\n            reasoning: SIMULATED_REPLY.reasoning,\n            sources: [...SIMULATED_REPLY.sources],\n            suggestions: [...SIMULATED_REPLY.suggestions],\n            text: SIMULATED_REPLY.text,\n            timestamp: formatClock(new Date()),\n            tool: { ...SIMULATED_REPLY.tool },\n          },\n        ]);\n        setStreamed(\"\");\n      }\n    }, STREAM_INTERVAL_MS);\n    return () => clearInterval(interval);\n  }, [phase]);\n\n  const send = (value: string) => {\n    const draft = value.trim();\n    if (!draft || phase !== \"idle\") {\n      return;\n    }\n\n    clearTimers();\n    const attachments = draftAttachments;\n    setDraftAttachments([]);\n    setLocalTurns((current) => [\n      ...current,\n      {\n        attachments: attachments.length > 0 ? attachments : undefined,\n        from: \"user\",\n        id: `live-u-${current.length}`,\n        text: draft,\n        timestamp: formatClock(new Date()),\n      },\n    ]);\n    setPhase(\"thinking\");\n    // Stand-ins for a request's stages. No model runs here.\n    timers.current.push(setTimeout(() => setPhase(\"tool\"), THINK_MS));\n    timers.current.push(setTimeout(() => setPhase(\"streaming\"), TOOL_MS));\n  };\n\n  const stop = () => {\n    clearTimers();\n    setPhase(\"idle\");\n    setStreamed(\"\");\n  };\n\n  // Memoised because the token breakdown below depends on it; a fresh array each\n  // render would make that memo pointless.\n  const allTurns = useMemo(\n    () => [...turns, ...localTurns],\n    [turns, localTurns]\n  );\n  const isBusy = phase !== \"idle\";\n  const state: AIState = phase === \"streaming\" ? \"streaming\" : \"thinking\";\n  // Derived from what is actually on screen, so an empty chat reads as empty\n  // instead of inheriting a fixture's 67k. A fixed number here was a lie the\n  // moment you pressed \"New chat\".\n  const breakdown = useMemo(() => {\n    // Everything the model would actually have been sent, not just the prose:\n    // a reasoning trace and an artifact's source are the bulk of a real turn.\n    const transcript =\n      allTurns.reduce((total, turn) => {\n        if (turn.from === \"user\") {\n          return total + turn.text.length;\n        }\n        return (\n          total +\n          (turn.text?.length ?? 0) +\n          (turn.reasoning?.length ?? 0) +\n          (turn.artifact?.code.length ?? 0) +\n          (turn.tool ? turn.tool.args.length + turn.tool.result.length : 0) +\n          (turn.diff?.lines.reduce(\n            (chars, line) => chars + line.content.length,\n            0\n          ) ?? 0)\n        );\n      }, 0) / CHARS_PER_TOKEN;\n    const retrieved = allTurns.reduce(\n      (total, turn) =>\n        total +\n        (turn.from === \"assistant\" && turn.sources\n          ? turn.sources.length * TOKENS_PER_SOURCE\n          : 0) +\n        (turn.from === \"user\" && turn.attachments\n          ? turn.attachments.length * TOKENS_PER_ATTACHMENT\n          : 0),\n      0\n    );\n\n    return [\n      { label: \"System\", tokens: SYSTEM_TOKENS },\n      {\n        label: \"Transcript\",\n        tokens: Math.round(transcript + streamed.length / CHARS_PER_TOKEN),\n      },\n      ...(retrieved > 0\n        ? [{ label: \"Retrieved files\", tokens: retrieved }]\n        : []),\n    ];\n  }, [allTurns, streamed]);\n\n  const used = breakdown.reduce((total, item) => total + item.tokens, 0);\n\n  return (\n    <section className={cn(\"flex min-w-0 flex-1 flex-col\", className)}>\n      <header className=\"flex items-center gap-3 border-border/60 border-b px-4 py-2.5\">\n        {/* The only way into the conversation list on a phone, where the sidebar\n            has no column of its own. */}\n        {onOpenSidebar ? (\n          <button\n            aria-label=\"Open conversations\"\n            className=\"-ml-1 flex cursor-pointer items-center rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden\"\n            onClick={onOpenSidebar}\n            type=\"button\"\n          >\n            <PanelLeftOpen aria-hidden=\"true\" size={16} />\n          </button>\n        ) : null}\n        <h2 className=\"min-w-0 flex-1 truncate font-medium text-sm\">{title}</h2>\n        <AIContextMeter\n          breakdown={breakdown}\n          limit={CONTEXT_LIMIT}\n          used={used}\n        />\n      </header>\n\n      <AIConversation\n        className=\"flex-1 px-4\"\n        contentKey={`${allTurns.length}-${streamed.length}-${phase}`}\n      >\n        {/* `px-2`, not decoration: the scroller clips at its padding box, and the\n              orb avatar sat flush against that edge — its glow extends past its\n              own 26px box, so the left of it was being shaved off. */}\n        <div className=\"mx-auto flex min-h-full w-full max-w-2xl flex-col gap-5 px-2 py-5\">\n          {allTurns.length === 0 && (\n            <div className=\"flex flex-1 flex-col items-center justify-center gap-4 py-10 text-center\">\n              <SiriOrb size=\"64px\" state=\"idle\" />\n              <p className=\"text-muted-foreground text-sm\">\n                Ask about sales, stores or staffing.\n              </p>\n              <AISuggestions\n                onSelect={(suggestion) => send(suggestion.label)}\n                suggestions={STARTER_SUGGESTIONS}\n              />\n            </div>\n          )}\n\n          {allTurns.map((turn) => (\n            <ChatTurnView key={turn.id} onSuggestion={send} turn={turn} />\n          ))}\n\n          {isBusy && (\n            <AIMessage\n              avatar={<SiriOrb size=\"26px\" state={state} />}\n              bubble={false}\n              from=\"assistant\"\n            >\n              <div className=\"flex flex-col gap-3\">\n                <AIReasoning collapseWhenDone isStreaming>\n                  {SIMULATED_REPLY.reasoning}\n                </AIReasoning>\n\n                {phase !== \"thinking\" && (\n                  <AIToolCall\n                    args={<code>{SIMULATED_REPLY.tool.args}</code>}\n                    name={SIMULATED_REPLY.tool.name}\n                    result={<span>{SIMULATED_REPLY.tool.result}</span>}\n                    status={phase === \"tool\" ? \"running\" : \"success\"}\n                    summary={SIMULATED_REPLY.tool.summary}\n                  />\n                )}\n\n                {phase === \"streaming\" ? (\n                  <AIResponse\n                    citations={[...SIMULATED_REPLY.citations]}\n                    isStreaming\n                    text={streamed}\n                  />\n                ) : (\n                  <AILoader label=\"Thinking\" showElapsed variant=\"dots\" />\n                )}\n              </div>\n            </AIMessage>\n          )}\n        </div>\n      </AIConversation>\n\n      <div className=\"border-border/60 border-t px-4 py-3\">\n        <div className=\"mx-auto w-full max-w-2xl\">\n          <AIPromptInput\n            attachments={draftAttachments}\n            maxLength={2000}\n            onAttach={() => {\n              // Stands in for a file dialog: adds the next unattached file, so\n              // the control does something real instead of miming it.\n              setDraftAttachments((current) => {\n                const next = ATTACHABLE_FILES.find(\n                  (file) => !current.some((item) => item.id === file.id)\n                );\n                return next ? [...current, next] : current;\n              });\n            }}\n            onRemoveAttachment={(id) =>\n              setDraftAttachments((current) =>\n                current.filter((file) => file.id !== id)\n              )\n            }\n            onStop={stop}\n            onSubmit={send}\n            placeholder=\"Ask anything about the shop…\"\n            state={isBusy ? \"streaming\" : \"idle\"}\n          >\n            <ModelPicker onSelect={setModel} value={model} />\n          </AIPromptInput>\n          <p className=\"pt-2 text-center text-muted-foreground text-xs\">\n            Simulated responses. Nothing is sent anywhere.\n          </p>\n        </div>\n      </div>\n    </section>\n  );\n};\n\n/**\n * One transcript turn.\n *\n * Every assistant part is optional and rendered in the order an agent produces\n * it: thinking, tools, plan, changes, artifact, prose, then what it read. New AI\n * components slot in as one more branch here.\n */\nconst ChatTurnView = ({\n  onSuggestion,\n  turn,\n}: {\n  onSuggestion: (value: string) => void;\n  turn: ChatTurn;\n}) => {\n  if (turn.from === \"user\") {\n    return (\n      <AIMessage copyText={turn.text} from=\"user\" timestamp={turn.timestamp}>\n        <span className=\"flex flex-col gap-2\">\n          {turn.text}\n          {turn.attachments && turn.attachments.length > 0 && (\n            <span className=\"flex flex-wrap justify-end gap-1.5\">\n              {turn.attachments.map((file) => (\n                <span\n                  className=\"flex items-center gap-1.5 rounded-lg bg-background/15 px-2 py-1 text-xs\"\n                  key={file.id}\n                >\n                  <Paperclip aria-hidden=\"true\" size={11} />\n                  {file.name}\n                  <span className=\"opacity-70\">{formatBytes(file.size)}</span>\n                </span>\n              ))}\n            </span>\n          )}\n        </span>\n      </AIMessage>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <AIMessage\n        avatar={<SiriOrb size=\"26px\" state=\"done\" />}\n        bubble={false}\n        copyText={turn.text}\n        from=\"assistant\"\n        onRetry={() => {\n          // A replayed transcript has nothing to retry against.\n        }}\n        onVote={() => {\n          // Demo only — no telemetry leaves the page.\n        }}\n        timestamp={turn.timestamp}\n      >\n        <div className=\"flex flex-col gap-3\">\n          {turn.reasoning ? (\n            <AIReasoning collapseWhenDone duration={4}>\n              {turn.reasoning}\n            </AIReasoning>\n          ) : null}\n\n          {turn.tool ? (\n            <AIToolCall\n              args={<code>{turn.tool.args}</code>}\n              name={turn.tool.name}\n              result={<span>{turn.tool.result}</span>}\n              status=\"success\"\n              summary={turn.tool.summary}\n            />\n          ) : null}\n\n          {turn.tasks ? (\n            <AITaskList label={turn.tasks.label} tasks={turn.tasks.tasks} />\n          ) : null}\n\n          {turn.diff ? (\n            <AIDiff lines={turn.diff.lines} title={turn.diff.title} />\n          ) : null}\n\n          {turn.artifact ? (\n            <AIArtifact\n              code={\n                <pre className=\"whitespace-pre-wrap\">{turn.artifact.code}</pre>\n              }\n              copyText={turn.artifact.code}\n              preview={<SummerBannerPreview />}\n              title={turn.artifact.title}\n            />\n          ) : null}\n\n          {turn.text ? (\n            <AIResponse citations={turn.citations} text={turn.text} />\n          ) : null}\n\n          {turn.sources ? <AISources sources={turn.sources} /> : null}\n        </div>\n      </AIMessage>\n\n      {turn.approval ? (\n        <AIApproval\n          onDecide={() => {\n            // Demo only.\n          }}\n          options={turn.approval.options}\n          question={turn.approval.question}\n        />\n      ) : null}\n\n      {turn.suggestions ? (\n        <AISuggestions\n          label=\"Follow-ups\"\n          onSelect={(suggestion) => onSuggestion(suggestion.label)}\n          suggestions={turn.suggestions}\n        />\n      ) : null}\n    </div>\n  );\n};\n\nconst KIB = 1024;\nconst MIB = KIB * KIB;\n\nconst formatBytes = (size?: number) => {\n  if (!size) {\n    return \"\";\n  }\n  return size >= MIB\n    ? `${(size / MIB).toFixed(1)} MB`\n    : `${Math.round(size / KIB)} KB`;\n};\n\n/**\n * Model picker for the composer.\n *\n * Small enough to keep local: the template should not drag a popover library in\n * for one menu, and the pattern is the same one the sidebar's account menu uses.\n */\nconst ModelPicker = ({\n  onSelect,\n  value,\n}: {\n  onSelect: (label: string) => void;\n  value: string;\n}) => {\n  const [isOpen, setIsOpen] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    const onPointerDown = (event: PointerEvent) => {\n      if (!containerRef.current?.contains(event.target as Node)) {\n        setIsOpen(false);\n      }\n    };\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setIsOpen(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown);\n      document.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [isOpen]);\n\n  return (\n    <div className=\"relative\" ref={containerRef}>\n      <button\n        aria-expanded={isOpen}\n        aria-haspopup=\"menu\"\n        className=\"flex items-center gap-1 rounded-lg px-2 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground\"\n        onClick={() => setIsOpen((open) => !open)}\n        type=\"button\"\n      >\n        {value}\n        <ChevronDown aria-hidden=\"true\" size={12} />\n      </button>\n\n      {isOpen ? (\n        <div\n          className=\"absolute bottom-full left-0 z-10 mb-2 w-52 overflow-hidden rounded-xl border border-border/60 bg-background p-1 shadow-black/10 shadow-lg\"\n          role=\"menu\"\n        >\n          {MODELS.map((option) => (\n            <button\n              className={cn(\n                \"flex w-full flex-col rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted\",\n                option.label === value\n                  ? \"text-foreground\"\n                  : \"text-muted-foreground\"\n              )}\n              key={option.id}\n              onClick={() => {\n                onSelect(option.label);\n                setIsOpen(false);\n              }}\n              role=\"menuitem\"\n              type=\"button\"\n            >\n              <span className=\"text-sm\">{option.label}</span>\n              <span className=\"text-muted-foreground text-xs\">\n                {option.note}\n              </span>\n            </button>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n};\n\n/** The artifact's rendered pane — the real banner, not a grey box. */\nconst SummerBannerPreview = () => (\n  <section className=\"rounded-2xl bg-gradient-to-br from-pink-200 to-sky-200 p-6 text-neutral-900\">\n    <p className=\"text-[0.65rem] uppercase tracking-widest\">Summer 2026</p>\n    <h3 className=\"mt-1.5 font-semibold text-2xl\">Two scoops, one price</h3>\n    <p className=\"mt-2 max-w-sm text-sm\">\n      Every weekday before 5pm, all summer.\n    </p>\n  </section>\n);\n\nexport default ChatThread;\n","path":"chat-thread.tsx","target":"components/smoothui/chat-template/chat-thread.tsx","type":"registry:block"},{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { type ChatConversation, CONVERSATIONS } from \"./chat-data\";\nimport ChatSidebar from \"./chat-sidebar\";\nimport ChatThread from \"./chat-thread\";\n\nconst NEW_CHAT_ID = \"__new__\";\n\n/** No overshoot: a drawer that bounces past its edge reads as a bug. */\nconst DRAWER_SPRING = { bounce: 0, duration: 0.3, type: \"spring\" as const };\nconst SCRIM_FADE = { duration: 0.2 };\n\nexport type ChatTemplateProps = {\n  className?: string;\n  /** Which conversation opens first. Defaults to the newest one. */\n  defaultConversationId?: string;\n  /** Swap in your own transcripts. The shape is `ChatConversation`. */\n  conversations?: ChatConversation[];\n};\n\n/**\n * A full chat surface — sidebar, thread, composer — wired to a fixed script.\n *\n * There is no model behind it and no network call anywhere: every reply is\n * scripted in `chat-data.ts`. Point the composer at your own endpoint and the\n * rest of the template is already the product.\n */\nconst ChatTemplate = ({\n  className,\n  conversations = CONVERSATIONS,\n  defaultConversationId,\n}: ChatTemplateProps) => {\n  const [activeId, setActiveId] = useState(\n    defaultConversationId ?? conversations[0]?.id ?? NEW_CHAT_ID\n  );\n  const [isSidebarOpen, setIsSidebarOpen] = useState(true);\n  // Narrow screens have no column for the list, so it arrives as a drawer.\n  const [isDrawerOpen, setIsDrawerOpen] = useState(false);\n  const shouldReduceMotion = useReducedMotion();\n\n  const open = (id: string) => {\n    setActiveId(id);\n    setIsDrawerOpen(false);\n  };\n\n  const active = conversations.find(\n    (conversation) => conversation.id === activeId\n  );\n\n  return (\n    <div\n      className={cn(\n        \"relative flex h-full min-h-0 w-full overflow-hidden bg-background text-foreground\",\n        className\n      )}\n    >\n      {/* Collapses to an icon rail rather than disappearing, so navigation stays\n          one click away. */}\n      <ChatSidebar\n        activeId={activeId}\n        className=\"hidden md:flex\"\n        collapsed={!isSidebarOpen}\n        conversations={conversations}\n        onNewChat={() => open(NEW_CHAT_ID)}\n        onSelect={open}\n        onToggleCollapsed={() => setIsSidebarOpen((isOpen) => !isOpen)}\n      />\n\n      {/* Below `md` the same list slides over the thread. Without it there is no\n          way to change conversation on a phone, which is most of what a chat\n          app's navigation is for.\n\n          Both children are keyed: `AnimatePresence` tracks its direct children by\n          key, and without one it never runs the enter or the exit — the panel\n          just sat parked off-screen. */}\n      <AnimatePresence>\n        {isDrawerOpen ? (\n          <>\n            <motion.button\n              animate={{ opacity: 1 }}\n              aria-label=\"Close conversations\"\n              className=\"absolute inset-0 z-20 cursor-default bg-foreground/20 md:hidden\"\n              exit={{ opacity: 0 }}\n              initial={{ opacity: 0 }}\n              key=\"chat-drawer-scrim\"\n              onClick={() => setIsDrawerOpen(false)}\n              transition={shouldReduceMotion ? { duration: 0 } : SCRIM_FADE}\n              type=\"button\"\n            />\n            {/* Reduced motion keeps the fade and drops the travel, rather than\n                removing the transition altogether. */}\n            <motion.div\n              animate={{ opacity: 1, x: 0 }}\n              className=\"absolute inset-y-0 left-0 z-30 flex md:hidden\"\n              exit={\n                shouldReduceMotion ? { opacity: 0 } : { opacity: 0, x: \"-100%\" }\n              }\n              initial={\n                shouldReduceMotion ? { opacity: 0 } : { opacity: 1, x: \"-100%\" }\n              }\n              key=\"chat-drawer-panel\"\n              transition={shouldReduceMotion ? SCRIM_FADE : DRAWER_SPRING}\n            >\n              <ChatSidebar\n                activeId={activeId}\n                className=\"h-full bg-background shadow-black/10 shadow-xl\"\n                collapsed={false}\n                conversations={conversations}\n                onNewChat={() => open(NEW_CHAT_ID)}\n                onSelect={open}\n                onToggleCollapsed={() => setIsDrawerOpen(false)}\n              />\n            </motion.div>\n          </>\n        ) : null}\n      </AnimatePresence>\n\n      <ChatThread\n        key={activeId}\n        onOpenSidebar={() => setIsDrawerOpen(true)}\n        title={active?.title ?? \"New chat\"}\n        turns={active?.turns ?? []}\n      />\n    </div>\n  );\n};\n\nexport default ChatTemplate;\nexport type { ChatConversation, ChatTurn } from \"./chat-data\";\n","path":"index.tsx","target":"components/smoothui/chat-template/index.tsx","type":"registry:block"}],"name":"chat-template","registryDependencies":["https://smoothui.dev/r/ai-approval.json","https://smoothui.dev/r/ai-artifact.json","https://smoothui.dev/r/ai-context-meter.json","https://smoothui.dev/r/ai-conversation.json","https://smoothui.dev/r/ai-core.json","https://smoothui.dev/r/ai-diff.json","https://smoothui.dev/r/ai-loader.json","https://smoothui.dev/r/ai-message.json","https://smoothui.dev/r/ai-prompt-input.json","https://smoothui.dev/r/ai-reasoning.json","https://smoothui.dev/r/ai-response.json","https://smoothui.dev/r/ai-sources.json","https://smoothui.dev/r/ai-suggestions.json","https://smoothui.dev/r/ai-task-list.json","https://smoothui.dev/r/ai-tool-call.json","https://smoothui.dev/r/siri-orb.json"],"title":"Chat Template","type":"registry:block"}