{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat",
  "title": "Mastra Chat",
  "description": "The full Agent Controller shell: conversation history sidebar, chat, and the agent workbench (browser, files, memory, schedules).",
  "dependencies": [
    "ai",
    "lucide-react",
    "shiki@^3.23.0",
    "sonner"
  ],
  "registryDependencies": [
    "dialog",
    "dropdown-menu",
    "tabs",
    "@mastra-chat-kit/agent",
    "@mastra-chat-kit/code-block",
    "@mastra-chat-kit/context",
    "@mastra-chat-kit/tool",
    "@mastra-chat-kit/chat-engine",
    "@mastra-chat-kit/chat-tool-views",
    "https://ai-sdk.dev/elements/api/registry/attachments.json",
    "https://ai-sdk.dev/elements/api/registry/confirmation.json",
    "https://ai-sdk.dev/elements/api/registry/conversation.json",
    "https://ai-sdk.dev/elements/api/registry/file-tree.json",
    "https://ai-sdk.dev/elements/api/registry/message.json",
    "https://ai-sdk.dev/elements/api/registry/model-selector.json",
    "https://ai-sdk.dev/elements/api/registry/prompt-input.json",
    "https://ai-sdk.dev/elements/api/registry/queue.json",
    "https://ai-sdk.dev/elements/api/registry/reasoning.json",
    "https://ai-sdk.dev/elements/api/registry/shimmer.json",
    "https://ai-sdk.dev/elements/api/registry/suggestion.json",
    "https://ai-sdk.dev/elements/api/registry/task.json",
    "https://ai-sdk.dev/elements/api/registry/terminal.json",
    "sonner",
    "@mastra-chat-kit/chat-routes"
  ],
  "files": [
    {
      "path": "components/chat/chat-switcher.tsx",
      "content": "'use client';\n\nimport { PanelLeftIcon, PanelRightIcon } from 'lucide-react';\nimport { useState } from 'react';\nimport { AgentControllerChat } from '@/components/chat/agent-controller-chat';\nimport { AgentControllerSidebar } from '@/components/chat/agent-controller-sidebar';\nimport { WorkbenchPanel } from '@/components/chat/workbench-panel';\nimport { useAgentControllerChat } from '@/lib/agent-controller/use-agent-controller-chat';\n\n/**\n * The app shell — sidebar │ chat │ workbench, no top header bar so the chat runs\n * edge to edge. The sidebar-collapse control lives at the top of the sidebar (and\n * floats top-left when the sidebar is collapsed, so it's always reachable); the\n * workbench toggle floats in the chat's empty top-right gutter.\n *\n * One controller session (an `AgentController` with a real Workspace: filesystem +\n * shell sandbox + browser) backs all three panes, so history, transcript, and the\n * workbench's Files/Terminal/Browser reflect the same run.\n */\nexport function ChatSwitcher() {\n  const [leftCollapsed, setLeftCollapsed] = useState(false);\n  // Workbench starts CLOSED so the default view is a clean chat, not an IDE.\n  const [rightCollapsed, setRightCollapsed] = useState(true);\n  const controller = useAgentControllerChat();\n\n  // New chat: clear the transcript, then focus the composer so it's obviously\n  // responsive — from an already-empty chat there'd otherwise be no visible change.\n  const handleNew = () => {\n    controller.reset();\n    requestAnimationFrame(() => {\n      document\n        .querySelector<HTMLTextAreaElement>('textarea[data-slot=\"input-group-control\"]')\n        ?.focus();\n    });\n  };\n\n  return (\n    // Recessed frame: the shell + both rails share the sidebar tone; the chat floats inset\n    // as a raised rounded panel (the \"inset\" layout — clean, subtle separation).\n    <div className=\"relative flex h-dvh overflow-hidden bg-sidebar\">\n      <AgentControllerSidebar\n        activeThreadId={controller.activeThreadId}\n        onSelect={controller.openThread}\n        onNew={handleNew}\n        refreshSignal={controller.refreshSignal}\n        collapsed={leftCollapsed}\n        onToggleCollapse={() => setLeftCollapsed((v) => !v)}\n      />\n\n      {/* Collapsed → a floating control brings the sidebar back (same spot as the\n          in-sidebar toggle, so it appears to stay put). */}\n      {leftCollapsed && (\n        <button\n          type=\"button\"\n          aria-label=\"Show conversations\"\n          onClick={() => setLeftCollapsed(false)}\n          className=\"absolute top-2.5 left-2.5 z-20 flex size-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground active:scale-[0.96]\"\n        >\n          <PanelLeftIcon className=\"size-4\" />\n        </button>\n      )}\n\n      <div className=\"relative flex min-h-0 min-w-0 flex-1\">\n        {/* The chat is the raised, floating panel: inset margin + rounded + border + soft\n            shadow, over the recessed sidebar-tone frame. */}\n        <div className=\"m-1.5 flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border bg-background shadow-sm\">\n          <AgentControllerChat controller={controller} />\n        </div>\n\n        {/* Only when the panel is CLOSED does the toggle float in the chat's empty\n            top-right gutter — open, it would overlap the panel, so the collapse\n            control lives in the panel's own header instead. */}\n        {rightCollapsed && (\n          <button\n            type=\"button\"\n            aria-label=\"Show workbench\"\n            onClick={() => setRightCollapsed(false)}\n            className=\"absolute top-2.5 right-2.5 z-20 flex size-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground active:scale-[0.96]\"\n          >\n            <PanelRightIcon className=\"size-4\" />\n          </button>\n        )}\n\n        {!rightCollapsed && (\n          <WorkbenchPanel controller={controller} onCollapse={() => setRightCollapsed(true)} />\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chat/chat-switcher.tsx"
    },
    {
      "path": "components/chat/composer.tsx",
      "content": "'use client';\n\nimport type { ChatStatus } from 'ai';\nimport { CheckIcon, ChevronLeftIcon, ChevronRightIcon, GlobeIcon } from 'lucide-react';\nimport { type ReactNode, useState } from 'react';\nimport {\n  Attachment,\n  AttachmentPreview,\n  AttachmentRemove,\n  Attachments,\n} from '@/components/ai-elements/attachments';\nimport {\n  ModelSelector,\n  ModelSelectorContent,\n  ModelSelectorItem,\n  ModelSelectorList,\n  ModelSelectorLogo,\n  ModelSelectorName,\n  ModelSelectorTrigger,\n} from '@/components/ai-elements/model-selector';\nimport {\n  PromptInput,\n  PromptInputActionAddAttachments,\n  PromptInputActionAddScreenshot,\n  PromptInputActionMenu,\n  PromptInputActionMenuContent,\n  PromptInputActionMenuTrigger,\n  PromptInputBody,\n  PromptInputButton,\n  PromptInputFooter,\n  PromptInputHeader,\n  type PromptInputMessage,\n  PromptInputSubmit,\n  PromptInputTextarea,\n  PromptInputTools,\n  usePromptInputAttachments,\n} from '@/components/ai-elements/prompt-input';\n\ntype Provider = 'anthropic' | 'openai';\nexport type ModelOption = { id: string; name: string; provider: Provider };\n\n// Model router ids (provider/model). Keep in sync with MODEL_ALLOWLIST in the\n// server's mastra/index.ts. OpenAI entries are the cheaper chat tier on purpose.\n// `name` is the DISPLAY label — kept short (no \"Claude\" prefix; the provider logo\n// beside it already conveys the vendor). OpenAI names keep \"GPT\" (it's the model\n// name, not a vendor word).\nexport const MODELS: ModelOption[] = [\n  { id: 'anthropic/claude-sonnet-4-6', name: 'Sonnet 4.6', provider: 'anthropic' },\n  { id: 'anthropic/claude-opus-4-8', name: 'Opus 4.8', provider: 'anthropic' },\n  { id: 'anthropic/claude-haiku-4-5', name: 'Haiku 4.5', provider: 'anthropic' },\n  { id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 mini', provider: 'openai' },\n  { id: 'openai/gpt-4o-mini', name: 'GPT-4o mini', provider: 'openai' },\n  { id: 'openai/gpt-4.1-nano', name: 'GPT-4.1 nano', provider: 'openai' },\n];\n\nconst MODEL_GROUPS: { provider: Provider; heading: string }[] = [\n  { provider: 'anthropic', heading: 'Anthropic' },\n  { provider: 'openai', heading: 'OpenAI' },\n];\n\nexport type ComposerSubmit = {\n  text: string;\n  model: string;\n  webSearch: boolean;\n  files?: PromptInputMessage['files'];\n};\n\n/** Renders the in-progress attachment chips above the textarea. */\nfunction AttachmentsDisplay() {\n  const attachments = usePromptInputAttachments();\n  if (attachments.files.length === 0) {\n    return null;\n  }\n  return (\n    <Attachments variant=\"inline\">\n      {attachments.files.map((file) => (\n        <Attachment data={file} key={file.id} onRemove={() => attachments.remove(file.id)}>\n          <AttachmentPreview />\n          <AttachmentRemove />\n        </Attachment>\n      ))}\n    </Attachments>\n  );\n}\n\n/**\n * The ONE chat composer — full PromptInput surface (attachments + drag-drop,\n * action menu, web-search toggle, model selector, submit). Kept separate from\n * the chat view so the input surface can be reused behind any transport —\n * only the behaviour behind `onSend` changes.\n */\nexport function Composer({\n  onSend,\n  status,\n  className = 'm-4',\n  footerExtra,\n  toolsExtra,\n}: {\n  onSend: (submit: ComposerSubmit) => void;\n  status?: ChatStatus;\n  className?: string;\n  /** Rendered in the footer, right of the tools (e.g. the live token-usage Context). */\n  footerExtra?: ReactNode;\n  /** Rendered at the START of the tools row (e.g. the controller mode switcher). */\n  toolsExtra?: ReactNode;\n}) {\n  const [text, setText] = useState('');\n  const [model, setModel] = useState(MODELS[0].id);\n  const [modelOpen, setModelOpen] = useState(false);\n  const [webSearch, setWebSearch] = useState(false);\n  const currentModel = MODELS.find((m) => m.id === model) ?? MODELS[0];\n\n  // The model selector pages by provider: arrows switch provider, its models list\n  // underneath. Opening the palette starts on the current model's provider.\n  const [activeProvider, setActiveProvider] = useState<Provider>(currentModel.provider);\n  const providerIdx = Math.max(\n    0,\n    MODEL_GROUPS.findIndex((g) => g.provider === activeProvider),\n  );\n  const activeGroup = MODEL_GROUPS[providerIdx];\n  const cycleProvider = (dir: 1 | -1) =>\n    setActiveProvider(\n      MODEL_GROUPS[(providerIdx + dir + MODEL_GROUPS.length) % MODEL_GROUPS.length].provider,\n    );\n\n  const handleSubmit = (message: PromptInputMessage) => {\n    const hasText = Boolean(message.text?.trim());\n    const hasAttachments = Boolean(message.files?.length);\n    if (!hasText && !hasAttachments) {\n      return;\n    }\n    onSend({ text: message.text ?? '', model, webSearch, files: message.files });\n    setText('');\n  };\n\n  return (\n    <PromptInput onSubmit={handleSubmit} className={className} globalDrop multiple>\n      <PromptInputHeader>\n        <AttachmentsDisplay />\n      </PromptInputHeader>\n      <PromptInputBody>\n        <PromptInputTextarea\n          onChange={(e) => setText(e.target.value)}\n          value={text}\n          placeholder=\"Ask anything…\"\n        />\n      </PromptInputBody>\n      <PromptInputFooter>\n        <PromptInputTools>\n          {toolsExtra}\n          <PromptInputActionMenu>\n            <PromptInputActionMenuTrigger />\n            <PromptInputActionMenuContent>\n              <PromptInputActionAddAttachments />\n              <PromptInputActionAddScreenshot />\n            </PromptInputActionMenuContent>\n          </PromptInputActionMenu>\n          <PromptInputButton\n            onClick={() => setWebSearch((v) => !v)}\n            tooltip={{ content: 'Search the web', shortcut: '⌘K' }}\n            variant={webSearch ? 'default' : 'ghost'}\n            className=\"transition active:scale-[0.96]\"\n          >\n            <GlobeIcon className=\"size-4\" />\n            <span>Search</span>\n          </PromptInputButton>\n          {/* The Model Selector element. Paged by provider: ◀ / ▶ switch provider,\n              its models list underneath. The chosen model is sent on every turn via\n              body.model and honored server-side. */}\n          <ModelSelector\n            open={modelOpen}\n            onOpenChange={(open) => {\n              setModelOpen(open);\n              if (open) {\n                setActiveProvider(currentModel.provider);\n              }\n            }}\n          >\n            <ModelSelectorTrigger asChild>\n              <PromptInputButton\n                variant=\"ghost\"\n                tooltip={{ content: 'Choose model' }}\n                className=\"transition active:scale-[0.96]\"\n              >\n                <ModelSelectorLogo provider={currentModel.provider} />\n                <span>{currentModel.name}</span>\n              </PromptInputButton>\n            </ModelSelectorTrigger>\n            <ModelSelectorContent>\n              {/* Provider pager header — centered ◀ Provider ▶ cluster, kept clear of\n                  the dialog's built-in ✕ (top-right) so the Next arrow stays clickable. */}\n              <div className=\"flex items-center justify-center gap-3 border-border border-b px-2 py-2.5 pr-10\">\n                <button\n                  type=\"button\"\n                  aria-label=\"Previous provider\"\n                  onClick={() => cycleProvider(-1)}\n                  className=\"flex size-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground active:scale-[0.96]\"\n                >\n                  <ChevronLeftIcon className=\"size-4\" />\n                </button>\n                <span className=\"flex w-28 items-center justify-center gap-1.5 font-medium text-sm\">\n                  <ModelSelectorLogo provider={activeProvider} />\n                  {activeGroup.heading}\n                </span>\n                <button\n                  type=\"button\"\n                  aria-label=\"Next provider\"\n                  onClick={() => cycleProvider(1)}\n                  className=\"flex size-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground active:scale-[0.96]\"\n                >\n                  <ChevronRightIcon className=\"size-4\" />\n                </button>\n              </div>\n              {/* Models for the active provider */}\n              <ModelSelectorList className=\"p-1.5\">\n                {MODELS.filter((mo) => mo.provider === activeProvider).map((mo) => (\n                  <ModelSelectorItem\n                    key={mo.id}\n                    value={mo.id}\n                    className=\"my-0.5 gap-2\"\n                    onSelect={() => {\n                      setModel(mo.id);\n                      setModelOpen(false);\n                    }}\n                  >\n                    <ModelSelectorLogo provider={mo.provider} />\n                    <ModelSelectorName>{mo.name}</ModelSelectorName>\n                    {model === mo.id && <CheckIcon className=\"size-4 text-muted-foreground\" />}\n                  </ModelSelectorItem>\n                ))}\n              </ModelSelectorList>\n            </ModelSelectorContent>\n          </ModelSelector>\n        </PromptInputTools>\n        <div className=\"flex items-center gap-2\">\n          {footerExtra}\n          <PromptInputSubmit disabled={!text.trim() && status !== 'streaming'} status={status} />\n        </div>\n      </PromptInputFooter>\n    </PromptInput>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chat/composer.tsx"
    },
    {
      "path": "components/chat/agent-controller-chat.tsx",
      "content": "'use client';\n\nimport { BotIcon, CopyIcon, UserIcon } from 'lucide-react';\nimport { Agent, AgentContent, AgentHeader } from '@/components/ai-elements/agent';\nimport {\n  Confirmation,\n  ConfirmationAction,\n  ConfirmationActions,\n  ConfirmationRequest,\n  ConfirmationTitle,\n} from '@/components/ai-elements/confirmation';\nimport {\n  Context,\n  ContextContent,\n  ContextContentBody,\n  ContextContentHeader,\n  ContextInputUsage,\n  ContextOutputUsage,\n  ContextReasoningUsage,\n  ContextTrigger,\n} from '@/components/ai-elements/context';\nimport {\n  Conversation,\n  ConversationContent,\n  ConversationScrollButton,\n} from '@/components/ai-elements/conversation';\nimport {\n  Message,\n  MessageAction,\n  MessageActions,\n  MessageContent,\n  MessageResponse,\n} from '@/components/ai-elements/message';\nimport {\n  Queue,\n  QueueItem,\n  QueueItemContent,\n  QueueItemDescription,\n  QueueItemIndicator,\n  QueueList,\n} from '@/components/ai-elements/queue';\nimport { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning';\nimport { Shimmer } from '@/components/ai-elements/shimmer';\nimport { Suggestion } from '@/components/ai-elements/suggestion';\nimport { Task, TaskContent, TaskItem, TaskTrigger } from '@/components/ai-elements/task';\nimport {\n  Tool,\n  ToolContent,\n  ToolHeader,\n  ToolInput,\n  ToolOutput,\n} from '@/components/ai-elements/tool';\nimport { Composer, type ComposerSubmit } from '@/components/chat/composer';\nimport {\n  AskUserPrompt,\n  GeneratedImage,\n  GoalCard,\n  type KnowledgeResult,\n  KnowledgeSources,\n  PlanCard,\n  StepTrace,\n} from '@/components/chat/tool-views';\nimport {\n  type ActiveTool,\n  type AgentControllerContentPart,\n  collectToolResults,\n  type SubagentRun,\n} from '@/lib/agent-controller/events';\nimport type { UseAgentControllerChat } from '@/lib/agent-controller/use-agent-controller-chat';\nimport { cn } from '@/lib/utils';\n\n/**\n * Empty-state suggestion pills — a short label the user sees, and the fuller `prompt`\n * actually sent on click (so the pills read evenly while still exercising real tools).\n */\nconst STARTERS: { label: string; prompt: string }[] = [\n  { label: 'Weather in LA', prompt: \"What's the weather in Los Angeles?\" },\n  {\n    label: 'Fibonacci demo',\n    prompt: 'Create hello.js that prints the first 10 Fibonacci numbers, then run it.',\n  },\n  { label: 'List workspace files', prompt: 'List the files in the workspace.' },\n  { label: 'Latest Mastra release', prompt: 'Search the web for the latest Mastra release notes.' },\n];\n\n/** Round avatar next to each message: user (filled brand) / assistant (bot). */\nfunction MsgAvatar({ role }: { role: string }) {\n  const isUser = role === 'user';\n  return (\n    <div\n      className={cn(\n        'flex size-7 shrink-0 items-center justify-center rounded-full',\n        isUser\n          ? 'bg-primary text-primary-foreground'\n          : 'border border-border bg-card text-muted-foreground',\n      )}\n    >\n      {isUser ? <UserIcon className=\"size-3.5\" /> : <BotIcon className=\"size-3.5\" />}\n    </div>\n  );\n}\n\n/** Bot avatar + three bouncing dots — shown before the assistant reply streams. */\n/**\n * Shown between `agent_start` and the first assistant token. The event map\n * documents `agent_start` → the Shimmer element, so this renders Shimmer rather\n * than hand-rolled dots — otherwise /events claims a mapping the UI never uses.\n */\nfunction ThinkingIndicator() {\n  return (\n    <div className=\"flex items-center gap-3\">\n      {/* biome-ignore lint/a11y/useValidAriaRole: `role` is MsgAvatar's message-role prop, not an ARIA role */}\n      <MsgAvatar role=\"assistant\" />\n      <span aria-label=\"Assistant is responding\" role=\"status\">\n        <Shimmer className=\"text-muted-foreground text-sm\">Thinking…</Shimmer>\n      </span>\n    </div>\n  );\n}\n\n/**\n * Agent Controller chat — consumes the AgentController SSE (`useAgentControllerChat`) and renders its\n * full surface onto stock AI Elements: text, thinking\n * → Reasoning, tool calls → Tool, search results → Sources/InlineCitation, images →\n * Image, submit_plan → Plan, the step sequence → ChainOfThought, task_updated → Task,\n * approvals → Confirmation. Only the engine behind the shared <Composer> differs.\n */\nexport function AgentControllerChat({ controller }: { controller: UseAgentControllerChat }) {\n  const { transcript, status, sendMessage, approve, answerQuestion } = controller;\n  // Goals AND planning are agent-driven — the agent calls its own `setGoal` tool for a\n  // standing objective and the built-in `submit_plan` for tasks that warrant a plan, so\n  // there are no manual mode/goal controls in the composer. `clearGoal` backs the goal\n  // card's dismiss affordance (the user can abandon an active goal).\n  const { goal, clearGoal } = controller;\n  const {\n    messages,\n    tasks,\n    pendingApproval,\n    pendingSuspension,\n    usage,\n    info,\n    subagents,\n    activeTools,\n    error,\n    queuedFollowUps,\n  } = transcript;\n  const resultsById = collectToolResults(messages);\n  // Subagent runs keyed by the parent `subagent` tool-call id, so a `subagent`\n  // tool call in the transcript renders as the nested <Agent> card.\n  const subagentsById = new Map(subagents.map((r) => [r.toolCallId, r]));\n\n  const handleSend = ({ text, model, webSearch, files }: ComposerSubmit) =>\n    sendMessage(text, {\n      model,\n      webSearch,\n      files: files?.map((f) => ({ url: f.url, mediaType: f.mediaType, filename: f.filename })),\n    });\n\n  // Live token usage lives INSIDE the composer footer (not floating in the chat).\n  const contextSlot = usage ? (\n    <Context\n      usedTokens={usage.totalTokens ?? 0}\n      maxTokens={200_000}\n      modelId=\"anthropic/claude-haiku-4-5\"\n      usage={{\n        inputTokens: usage.promptTokens ?? 0,\n        outputTokens: usage.completionTokens ?? 0,\n        totalTokens: usage.totalTokens ?? 0,\n        reasoningTokens: usage.reasoningTokens,\n        cachedInputTokens: usage.cachedInputTokens,\n      }}\n    >\n      <ContextTrigger />\n      <ContextContent>\n        <ContextContentHeader />\n        <ContextContentBody>\n          <ContextInputUsage />\n          <ContextOutputUsage />\n          <ContextReasoningUsage />\n        </ContextContentBody>\n      </ContextContent>\n    </Context>\n  ) : null;\n\n  // White composer so it pops against the zinc canvas. Rendered under the hero on the\n  // empty state, or pinned at the bottom once the chat is going. The token-usage Context\n  // rides in its footer. No composer controls for modes/goals — those are agent-driven.\n  const composer = (\n    <Composer\n      onSend={handleSend}\n      status={status === 'streaming' ? 'streaming' : status === 'error' ? 'error' : 'ready'}\n      className=\"m-0 [&_[data-slot=input-group]]:border-border [&_[data-slot=input-group]]:bg-card [&_[data-slot=input-group]]:shadow-[var(--shadow-float)]\"\n      footerExtra={contextSlot}\n    />\n  );\n\n  // Suggestion pills → a fuller prompt on click. Reused in the empty state above the composer.\n  const starterPills = (\n    <div className=\"flex w-full max-w-3xl flex-wrap items-center justify-center gap-2\">\n      {STARTERS.map((s) => (\n        <Suggestion\n          key={s.prompt}\n          suggestion={s.prompt}\n          onClick={(prompt) => handleSend({ text: prompt, model: '', webSearch: false })}\n          className=\"animate-fade-up\"\n        >\n          {s.label}\n        </Suggestion>\n      ))}\n    </div>\n  );\n\n  return (\n    // Flat chat pane. NO h-full here — an explicit height opts the flex item out of\n    // align-stretch and then collapses to content height; letting it stretch to the\n    // row is what actually fills the column. min-h-0 lets the conversation scroll.\n    <div className=\"flex min-h-0 w-full min-w-0 flex-1 flex-col\">\n      {messages.length === 0 && status !== 'streaming' ? (\n        // Empty state: hero + suggestion pills + composer, centered as one group. The pills\n        // sit ABOVE the composer so they read as prompts leading into the input.\n        <div className=\"flex flex-1 flex-col items-center justify-center gap-6 px-4\">\n          <div className=\"animate-fade-up space-y-2 text-center\">\n            <h1 className=\"text-balance font-semibold text-3xl tracking-tight sm:text-4xl\">\n              What&rsquo;s on your mind today?\n            </h1>\n            <p className=\"text-base text-muted-foreground\">\n              Ask a question, run some code, or browse the web.\n            </p>\n          </div>\n          {starterPills}\n          <div className=\"w-full max-w-3xl\">{composer}</div>\n          {goal && (\n            <div className=\"w-full max-w-3xl\">\n              <GoalCard goal={goal} onClear={clearGoal} />\n            </div>\n          )}\n        </div>\n      ) : (\n        <Conversation className=\"flex-1\">\n          <ConversationContent className=\"mx-auto w-full max-w-3xl pt-10\">\n            {/* Goal card pinned above the conversation — the live judge verdict for the\n                active objective (updates as goal_evaluation events stream in). */}\n            {goal && <GoalCard goal={goal} onClear={clearGoal} />}\n            {messages\n              .filter((m) => m.role === 'user' || m.role === 'assistant')\n              .map((m) => {\n                // The agent's actual tool-call sequence → a ChainOfThought trace.\n                const steps =\n                  m.role === 'assistant'\n                    ? m.content\n                        .filter((p) => p.type === 'tool_call')\n                        .map((p) => `Called ${(p as { name: string }).name}`)\n                    : [];\n                return (\n                  <div\n                    key={m.id}\n                    className={cn(\n                      'flex w-full items-start gap-3',\n                      m.role === 'user' && 'flex-row-reverse',\n                    )}\n                  >\n                    <MsgAvatar role={m.role} />\n                    <Message from={m.role} className=\"min-w-0 max-w-[85%] flex-1\">\n                      <MessageContent>\n                        {steps.length > 0 && <StepTrace steps={steps} />}\n                        {m.content.map((part, i) =>\n                          renderContent(part, i, resultsById, subagentsById),\n                        )}\n                      </MessageContent>\n                      {m.role === 'assistant' && (\n                        <MessageActions>\n                          <MessageAction\n                            tooltip=\"Copy\"\n                            label=\"Copy\"\n                            onClick={() => copyAgentControllerMessage(m.content)}\n                          >\n                            <CopyIcon className=\"size-4\" />\n                          </MessageAction>\n                        </MessageActions>\n                      )}\n                    </Message>\n                  </div>\n                );\n              })}\n\n            {/* Live tool calls whose input is still streaming — shown until the settled\n                message part lands and suppresses them (698.25). No double-render: the\n                reducer drops an active entry the moment its tool_call message part exists. */}\n            {activeTools.map((t) => (\n              <ActiveToolCard key={`active-${t.toolCallId}`} tool={t} />\n            ))}\n\n            {tasks.length > 0 && (\n              <Task defaultOpen>\n                <TaskTrigger title={`Tasks (${tasks.length})`} />\n                <TaskContent>\n                  {tasks.map((t, i) => (\n                    <TaskItem key={t.id ?? `task-${i}`}>\n                      {t.status ? `[${t.status}] ` : ''}\n                      {t.content ?? t.title ?? 'Task'}\n                    </TaskItem>\n                  ))}\n                </TaskContent>\n              </Task>\n            )}\n\n            {pendingApproval && (\n              <Confirmation\n                state=\"approval-requested\"\n                approval={{ id: pendingApproval.toolCallId }}\n              >\n                <ConfirmationTitle>Run {pendingApproval.toolName}?</ConfirmationTitle>\n                <ConfirmationRequest>\n                  <pre className=\"overflow-x-auto text-xs\">\n                    {JSON.stringify(pendingApproval.args, null, 2)}\n                  </pre>\n                  <ConfirmationActions>\n                    <ConfirmationAction onClick={() => approve('approve')}>\n                      Approve\n                    </ConfirmationAction>\n                    <ConfirmationAction variant=\"outline\" onClick={() => approve('decline')}>\n                      Reject\n                    </ConfirmationAction>\n                  </ConfirmationActions>\n                </ConfirmationRequest>\n              </Confirmation>\n            )}\n\n            {/* Agent-driven ask_user: the run is suspended awaiting the user's answer\n                to a clarifying question. Answering resumes it on the open SSE. */}\n            {pendingSuspension && (\n              <AskUserPrompt suspension={pendingSuspension} onAnswer={answerQuestion} />\n            )}\n\n            {/* Bot avatar + typing dots while the run is in flight and the assistant\n              hasn't started its reply yet (otherwise the reply itself is the signal). */}\n            {status === 'streaming' &&\n              messages.filter((m) => m.role === 'user' || m.role === 'assistant').at(-1)?.role !==\n                'assistant' && <ThinkingIndicator />}\n\n            {/* Messages sent while a run was busy are queued server-side and replayed\n                when it settles (`follow_up_queued`). Without this the count is folded\n                into state and never shown, so the user gets no sign they landed. */}\n            {queuedFollowUps > 0 && (\n              <Queue className=\"px-2\">\n                <QueueList>\n                  <QueueItem>\n                    <QueueItemIndicator />\n                    <QueueItemContent>\n                      <QueueItemDescription>\n                        {queuedFollowUps === 1\n                          ? '1 message queued — it will send when this run finishes'\n                          : `${queuedFollowUps} messages queued — they will send when this run finishes`}\n                      </QueueItemDescription>\n                    </QueueItemContent>\n                  </QueueItem>\n                </QueueList>\n              </Queue>\n            )}\n\n            {/* Transient run status (controller `info` events). */}\n            {info && !error && <p className=\"px-2 text-muted-foreground text-xs italic\">{info}</p>}\n\n            {error && (\n              <p className=\"px-2 text-destructive text-sm\">AgentController error: {error}</p>\n            )}\n          </ConversationContent>\n          <ConversationScrollButton />\n        </Conversation>\n      )}\n\n      {/* Bottom composer only once a chat is going — the empty state has its own\n          centered one, so it never shows twice. */}\n      {!(messages.length === 0 && status !== 'streaming') && (\n        <div className=\"mx-auto w-full max-w-3xl px-4 pb-4\">{composer}</div>\n      )}\n    </div>\n  );\n}\n\n/** Copy the plain text of a controller assistant turn (text parts only). */\nfunction copyAgentControllerMessage(content: AgentControllerContentPart[]) {\n  const text = content\n    .filter((p) => p.type === 'text')\n    .map((p) => (p as { text: string }).text ?? '')\n    .join('\\n');\n  navigator.clipboard?.writeText(text);\n}\n\n/**\n * A subagent invocation → the <Agent> card: header (type + model + forked badge),\n * the delegated task, the subagent's nested tool calls, its streamed text, and a\n * live/`done`/error footer. Driven by the accumulated `subagent_*` events; falls\n * back to the parent tool-call `task` before any subagent event arrives.\n */\nfunction SubagentCard({ run, fallbackTask }: { run?: SubagentRun; fallbackTask?: string }) {\n  const agentType = run?.agentType ?? 'subagent';\n  const task = run?.task ?? fallbackTask;\n  const running = run?.status !== 'done';\n  return (\n    <Agent className=\"my-2 rounded-lg\">\n      <AgentHeader\n        name={`Subagent · ${agentType}${run?.forked ? ' (forked)' : ''}`}\n        model={run?.modelId}\n      />\n      <AgentContent className=\"pt-3\">\n        {task && <p className=\"text-muted-foreground text-xs\">Task: {task}</p>}\n        {run?.tools.map((t, ti) => (\n          // biome-ignore lint/suspicious/noArrayIndexKey: tool list is append-only, never reordered\n          <Tool key={`${t.name}-${ti}`}>\n            <ToolHeader\n              type={`tool-${t.name}`}\n              state={t.result !== undefined ? 'output-available' : 'input-available'}\n            />\n            <ToolContent>\n              <ToolInput input={t.args} />\n              {t.result !== undefined && (\n                <ToolOutput\n                  output={\n                    <pre className=\"overflow-x-auto text-xs\">\n                      {JSON.stringify(t.result, null, 2)}\n                    </pre>\n                  }\n                  errorText={t.isError ? 'Tool reported an error' : undefined}\n                />\n              )}\n            </ToolContent>\n          </Tool>\n        ))}\n        {run?.text && <MessageResponse>{run.text}</MessageResponse>}\n        {running ? (\n          <p className=\"animate-pulse text-muted-foreground text-xs italic\">Working…</p>\n        ) : run?.isError ? (\n          <p className=\"text-destructive text-xs\">Subagent reported an error.</p>\n        ) : null}\n      </AgentContent>\n    </Agent>\n  );\n}\n\n/**\n * A tool call whose input is still streaming — rendered from an `activeTools` entry\n * during the window before the settled `message_update` tool-invocation part arrives\n * (which then suppresses this via the reducer). Shows the input-streaming <Tool> state\n * with whatever args have streamed so far (best-effort parse of the partial JSON).\n */\nfunction ActiveToolCard({ tool }: { tool: ActiveTool }) {\n  let input: unknown = tool.argsText;\n  try {\n    if (tool.argsText.trim()) {\n      input = JSON.parse(tool.argsText);\n    }\n  } catch {\n    // Partial/streaming JSON — show the raw text until it parses.\n    input = tool.argsText;\n  }\n  return (\n    <Tool>\n      <ToolHeader type={`tool-${tool.name}`} state={tool.state} />\n      <ToolContent>\n        <ToolInput input={input} />\n      </ToolContent>\n    </Tool>\n  );\n}\n\nfunction renderContent(\n  part: AgentControllerContentPart,\n  i: number,\n  resultsById: Map<string, AgentControllerContentPart>,\n  subagentsById: Map<string, SubagentRun>,\n) {\n  if (part.type === 'text') {\n    return <MessageResponse key={i}>{(part as { text: string }).text}</MessageResponse>;\n  }\n  if (part.type === 'thinking') {\n    return (\n      <Reasoning key={i} defaultOpen={false}>\n        <ReasoningTrigger />\n        <ReasoningContent>{(part as { thinking: string }).thinking}</ReasoningContent>\n      </Reasoning>\n    );\n  }\n  if (part.type === 'tool_call') {\n    const call = part as { id: string; name: string; args: unknown };\n    const result = resultsById.get(call.id) as { result?: unknown; isError?: boolean } | undefined;\n    const output = result?.result;\n\n    // The agent's `setGoal` tool has no inline rendering — the GoalCard (pinned above\n    // the conversation, driven by goal_evaluation) is its surface, so suppress the raw call.\n    if (call.name === 'setGoal') {\n      return null;\n    }\n    // `ask_user` renders as the live AskUserPrompt (driven by tool_suspended), not a raw\n    // tool card — suppress the call so the clarifying question isn't shown twice.\n    if (call.name === 'ask_user') {\n      return null;\n    }\n    // The built-in `subagent` tool → the nested <Agent> card, driven by the\n    // subagent_* events accumulated for this tool-call id.\n    if (call.name === 'subagent') {\n      const run = subagentsById.get(call.id);\n      const task = (call.args as { task?: string } | undefined)?.task;\n      return <SubagentCard key={i} run={run} fallbackTask={task} />;\n    }\n    // submit_plan → the <Plan> element.\n    if (call.name === 'submit_plan') {\n      const a = call.args as { title?: string; plan?: string };\n      return <PlanCard key={i} title={a?.title} plan={a?.plan ?? ''} />;\n    }\n    // generateImage → the <Image> element (fetches bytes by id).\n    const img = output as { imageId?: string; mediaType?: string; prompt?: string } | undefined;\n    if (call.name === 'generateImage' && img?.imageId) {\n      return (\n        <GeneratedImage\n          key={i}\n          imageId={img.imageId}\n          mediaType={img.mediaType ?? 'image/webp'}\n          prompt={img.prompt}\n        />\n      );\n    }\n\n    const hasOutput = result !== undefined;\n    const searchResults =\n      call.name === 'searchKnowledge'\n        ? (output as { results?: KnowledgeResult[] } | undefined)?.results\n        : undefined;\n    return (\n      <div className=\"flex flex-col gap-2\" key={i}>\n        <Tool>\n          <ToolHeader\n            type={`tool-${call.name}`}\n            state={hasOutput ? 'output-available' : 'input-available'}\n          />\n          <ToolContent>\n            <ToolInput input={call.args} />\n            {hasOutput && (\n              <ToolOutput\n                output={\n                  <pre className=\"overflow-x-auto text-xs\">\n                    {JSON.stringify(result?.result, null, 2)}\n                  </pre>\n                }\n                errorText={result?.isError ? 'Tool reported an error' : undefined}\n              />\n            )}\n          </ToolContent>\n        </Tool>\n        {Array.isArray(searchResults) && <KnowledgeSources results={searchResults} />}\n      </div>\n    );\n  }\n  // tool_result is rendered alongside its tool_call; skip standalone.\n  if (part.type === 'tool_result') {\n    return null;\n  }\n  if (part.type === 'system_reminder') {\n    return (\n      <p className=\"text-muted-foreground text-xs\" key={i}>\n        {(part as { message: string }).message}\n      </p>\n    );\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/chat/agent-controller-chat.tsx"
    },
    {
      "path": "components/chat/agent-controller-sidebar.tsx",
      "content": "'use client';\n\nimport {\n  ArchiveIcon,\n  ArchiveRestoreIcon,\n  MoreHorizontalIcon,\n  PanelLeftIcon,\n  PencilIcon,\n  SearchIcon,\n  SquarePenIcon,\n  Trash2Icon,\n  XIcon,\n} from 'lucide-react';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { toast } from 'sonner';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport { type SearchHit, type ThreadItem, useThreads } from '@/lib/agent-controller/use-threads';\nimport { cn } from '@/lib/utils';\n\nconst DAY = 86_400_000;\nconst GROUP_ORDER = ['Today', 'Yesterday', 'Previous 7 days', 'Previous 30 days', 'Older'] as const;\n\nfunction groupOf(ts: number, todayStart: number): (typeof GROUP_ORDER)[number] {\n  if (ts >= todayStart) return 'Today';\n  if (ts >= todayStart - DAY) return 'Yesterday';\n  if (ts >= todayStart - 7 * DAY) return 'Previous 7 days';\n  if (ts >= todayStart - 30 * DAY) return 'Previous 30 days';\n  return 'Older';\n}\n\n/**\n * Agent Controller conversation history — the conversation-history left rail wired to the\n * controller's persisted threads (`/api/agent-controller/threads*`). New-chat, debounced\n * title/first-message search, date-grouped list, per-chat rename / archive+undo /\n * delete, and a collapsible Archived section. The shell (`ChatSwitcher`) owns the\n * active thread; `refreshSignal` bumps when a turn finishes so a new thread (and\n * its title) shows up.\n */\nexport function AgentControllerSidebar({\n  activeThreadId,\n  onSelect,\n  onNew,\n  refreshSignal,\n  collapsed,\n  onToggleCollapse,\n}: {\n  activeThreadId: string | null;\n  onSelect: (id: string) => void;\n  onNew: () => void;\n  refreshSignal: number;\n  collapsed: boolean;\n  onToggleCollapse: () => void;\n}) {\n  const [showArchived, setShowArchived] = useState(false);\n  const [pendingDelete, setPendingDelete] = useState<ThreadItem | null>(null);\n\n  // All thread data + mutations live in the engine so a second skin inherits them\n  // (bd h27). This component owns only presentation — including the toasts below,\n  // which is why the hook returns a boolean instead of notifying by itself.\n  const {\n    active,\n    archived,\n    search,\n    setSearch,\n    isSearching,\n    debouncedSearch,\n    searchHits,\n    searching,\n    archive,\n    rename,\n    remove,\n  } = useThreads({ refreshSignal });\n\n  // Active chats grouped by last-activity day, in fixed order.\n  const groups = useMemo(() => {\n    const todayStart = new Date().setHours(0, 0, 0, 0);\n    const buckets = new Map<string, ThreadItem[]>();\n    for (const t of active) {\n      const label = groupOf(+new Date(t.updatedAt ?? t.createdAt ?? todayStart), todayStart);\n      const arr = buckets.get(label) ?? [];\n      arr.push(t);\n      buckets.set(label, arr);\n    }\n    return GROUP_ORDER.map((label) => ({ label, items: buckets.get(label) ?? [] })).filter(\n      (g) => g.items.length > 0,\n    );\n  }, [active]);\n\n  const handleArchive = async (t: ThreadItem, nextArchived: boolean) => {\n    const ok = await archive(t, nextArchived);\n    if (!ok) {\n      toast.error(nextArchived ? 'Failed to archive' : 'Failed to restore');\n      return;\n    }\n    if (nextArchived) {\n      toast.success('Conversation archived', {\n        action: { label: 'Undo', onClick: () => handleArchive(t, false) },\n      });\n    } else {\n      toast.success('Conversation restored');\n    }\n  };\n\n  const handleRename = async (t: ThreadItem, title: string) => {\n    const next = title.trim();\n    if (!next || next === t.title) {\n      return;\n    }\n    if (await rename(t, next)) {\n      toast.success('Conversation renamed');\n    } else {\n      toast.error('Failed to rename');\n    }\n  };\n\n  const handleDelete = async (t: ThreadItem) => {\n    setPendingDelete(null);\n    if (await remove(t)) {\n      toast.success('Conversation deleted');\n      if (t.id === activeThreadId) onNew();\n    } else {\n      toast.error('Failed to delete');\n    }\n  };\n\n  return (\n    <aside\n      className={cn(\n        'shrink-0 overflow-hidden bg-sidebar transition-[width] duration-200 ease-out',\n        // Flush to the window edge — the sidebar is the recessed frame; the chat floats\n        // inset as the rounded panel (see ChatSwitcher). No border: the inset chat's gap\n        // (which reveals this sidebar tone) is what separates them.\n        collapsed ? 'w-0' : 'w-72',\n      )}\n    >\n      <div className=\"flex h-full w-72 flex-col\">\n        {/* Top bar: sidebar-collapse control (aligns with the floating one shown\n            when collapsed, so it looks like it stays put). */}\n        <div className=\"flex h-11 items-center px-2\">\n          <button\n            type=\"button\"\n            aria-label=\"Hide conversations\"\n            onClick={onToggleCollapse}\n            className=\"flex size-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-sidebar-accent hover:text-foreground active:scale-[0.96]\"\n          >\n            <PanelLeftIcon className=\"size-4\" />\n          </button>\n        </div>\n\n        {/* New chat */}\n        <div className=\"px-2 pb-2\">\n          <button\n            type=\"button\"\n            onClick={() => {\n              setSearch('');\n              onNew();\n            }}\n            className=\"flex h-10 w-full items-center gap-2 rounded-lg border border-border bg-background px-3 font-medium text-sm shadow-sm transition-[scale,background-color] hover:bg-accent active:scale-[0.98]\"\n          >\n            <SquarePenIcon className=\"size-4\" />\n            New chat\n          </button>\n        </div>\n\n        {/* Search */}\n        <div className=\"px-2 pb-2\">\n          <div className=\"relative\">\n            <SearchIcon className=\"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground\" />\n            <input\n              aria-label=\"Search conversations\"\n              value={search}\n              onChange={(e) => setSearch(e.target.value)}\n              placeholder=\"Search chats…\"\n              className=\"h-9 w-full rounded-lg border border-border bg-background pr-8 pl-9 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring\"\n            />\n            {search && (\n              <button\n                type=\"button\"\n                aria-label=\"Clear search\"\n                onClick={() => setSearch('')}\n                className=\"-translate-y-1/2 absolute top-1/2 right-1 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-[scale,color] hover:text-foreground active:scale-[0.96]\"\n              >\n                <XIcon className=\"size-4\" />\n              </button>\n            )}\n          </div>\n        </div>\n\n        {/* List */}\n        <div className=\"min-h-0 flex-1 overflow-y-auto px-2 pb-3\">\n          {isSearching ? (\n            <Section label={searching ? 'Searching…' : 'Results'}>\n              {searchHits.length === 0 && !searching ? (\n                <Empty>No matches for “{debouncedSearch}”</Empty>\n              ) : (\n                searchHits.map((h) => (\n                  <SearchRow\n                    key={h.id}\n                    hit={h}\n                    isActive={h.id === activeThreadId}\n                    onClick={() => {\n                      onSelect(h.id);\n                      setSearch('');\n                    }}\n                  />\n                ))\n              )}\n            </Section>\n          ) : (\n            <>\n              {active.length === 0 && (\n                <Empty>Your conversations will appear here once you start chatting.</Empty>\n              )}\n              {groups.map((g) => (\n                <Section key={g.label} label={g.label}>\n                  {g.items.map((t) => (\n                    <ChatRow\n                      key={t.id}\n                      thread={t}\n                      isActive={t.id === activeThreadId}\n                      onClick={() => onSelect(t.id)}\n                      onArchive={() => handleArchive(t, true)}\n                      onRename={(title) => handleRename(t, title)}\n                      onDelete={() => setPendingDelete(t)}\n                    />\n                  ))}\n                </Section>\n              ))}\n\n              {archived.length > 0 && (\n                <div className=\"mt-2\">\n                  <button\n                    type=\"button\"\n                    onClick={() => setShowArchived((v) => !v)}\n                    className=\"flex h-9 w-full items-center gap-2 rounded-md px-2 font-medium text-muted-foreground text-xs uppercase tracking-wide transition-colors hover:text-foreground\"\n                  >\n                    <ArchiveIcon className=\"size-3.5\" />\n                    {showArchived ? 'Hide archived' : `Archived (${archived.length})`}\n                  </button>\n                  {showArchived &&\n                    archived.map((t) => (\n                      <ChatRow\n                        key={t.id}\n                        thread={t}\n                        isActive={t.id === activeThreadId}\n                        onClick={() => onSelect(t.id)}\n                        onArchive={() => handleArchive(t, false)}\n                        onRename={(title) => handleRename(t, title)}\n                        onDelete={() => setPendingDelete(t)}\n                        archivedRow\n                      />\n                    ))}\n                </div>\n              )}\n            </>\n          )}\n        </div>\n      </div>\n\n      {/* Delete confirmation */}\n      <Dialog open={!!pendingDelete} onOpenChange={(o) => !o && setPendingDelete(null)}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>Delete this conversation?</DialogTitle>\n            <DialogDescription>\n              This permanently deletes “{pendingDelete?.title}” and its messages. This can’t be\n              undone.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <button\n              type=\"button\"\n              onClick={() => setPendingDelete(null)}\n              className=\"h-9 rounded-lg border border-border px-4 font-medium text-sm transition-[scale] active:scale-[0.96]\"\n            >\n              Cancel\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => pendingDelete && handleDelete(pendingDelete)}\n              className=\"h-9 rounded-lg bg-destructive px-4 font-medium text-destructive-foreground text-sm transition-[scale] active:scale-[0.96]\"\n            >\n              Delete\n            </button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </aside>\n  );\n}\n\nfunction Section({ label, children }: { label: string; children: React.ReactNode }) {\n  return (\n    <div className=\"mb-2\">\n      <div className=\"px-2 py-1 font-medium text-[10px] text-muted-foreground uppercase tracking-[0.12em]\">\n        {label}\n      </div>\n      <div className=\"flex flex-col gap-0.5\">{children}</div>\n    </div>\n  );\n}\n\nfunction Empty({ children }: { children: React.ReactNode }) {\n  return <div className=\"text-pretty px-2 py-2 text-muted-foreground text-xs\">{children}</div>;\n}\n\nfunction ChatRow({\n  thread,\n  isActive,\n  onClick,\n  onArchive,\n  onRename,\n  onDelete,\n  archivedRow,\n}: {\n  thread: ThreadItem;\n  isActive: boolean;\n  onClick: () => void;\n  onArchive: () => void;\n  onRename: (title: string) => void;\n  onDelete: () => void;\n  archivedRow?: boolean;\n}) {\n  const [renaming, setRenaming] = useState(false);\n  const [draft, setDraft] = useState(thread.title);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const startRename = () => {\n    setDraft(thread.title);\n    setRenaming(true);\n  };\n  const commit = () => {\n    setRenaming(false);\n    onRename(draft);\n  };\n\n  // Focus + select-all ONCE when rename mode opens — not on every keystroke. (A ref\n  // callback re-runs each render, which re-selected the text after every letter, so\n  // typing replaced the whole selection. This effect keys off `renaming` only.)\n  useEffect(() => {\n    if (renaming) {\n      const el = inputRef.current;\n      el?.focus();\n      el?.select();\n    }\n  }, [renaming]);\n\n  if (renaming) {\n    return (\n      <div className=\"flex items-center rounded-lg bg-accent/60 px-2\">\n        <input\n          ref={inputRef}\n          value={draft}\n          onChange={(e) => setDraft(e.target.value)}\n          onBlur={commit}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') {\n              commit();\n            } else if (e.key === 'Escape') {\n              setRenaming(false);\n            }\n          }}\n          className=\"h-10 min-w-0 flex-1 bg-transparent text-sm outline-none\"\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        'group flex items-center rounded-lg pr-1 transition-colors',\n        isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60',\n      )}\n    >\n      <button\n        type=\"button\"\n        onClick={onClick}\n        onDoubleClick={startRename}\n        className=\"flex h-10 min-w-0 flex-1 items-center px-2 text-left text-sm\"\n      >\n        <span className=\"truncate\">{thread.title}</span>\n      </button>\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <button\n            type=\"button\"\n            aria-label=\"Conversation actions\"\n            onClick={(e) => e.stopPropagation()}\n            className=\"flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-[scale,opacity,color] hover:text-foreground focus-visible:opacity-100 active:scale-[0.96] group-hover:opacity-100 data-[state=open]:opacity-100\"\n          >\n            <MoreHorizontalIcon className=\"size-4\" />\n          </button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"end\" className=\"w-40\">\n          <DropdownMenuItem onClick={startRename}>\n            <PencilIcon className=\"size-4\" />\n            Rename\n          </DropdownMenuItem>\n          <DropdownMenuItem onClick={onArchive}>\n            {archivedRow ? (\n              <>\n                <ArchiveRestoreIcon className=\"size-4\" />\n                Restore\n              </>\n            ) : (\n              <>\n                <ArchiveIcon className=\"size-4\" />\n                Archive\n              </>\n            )}\n          </DropdownMenuItem>\n          <DropdownMenuSeparator />\n          <DropdownMenuItem variant=\"destructive\" onClick={onDelete}>\n            <Trash2Icon className=\"size-4\" />\n            Delete\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n}\n\nfunction SearchRow({\n  hit,\n  isActive,\n  onClick,\n}: {\n  hit: SearchHit;\n  isActive: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        'flex min-h-10 w-full flex-col items-start gap-0.5 rounded-lg px-2 py-1.5 text-left transition-colors',\n        isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/60',\n      )}\n    >\n      <span className=\"w-full truncate text-sm\">{hit.title}</span>\n      {hit.snippet && (\n        <span className=\"w-full truncate text-muted-foreground text-xs\">{hit.snippet}</span>\n      )}\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chat/agent-controller-sidebar.tsx"
    },
    {
      "path": "components/chat/workbench-panel.tsx",
      "content": "'use client';\n\nimport {\n  BrainIcon,\n  CalendarClockIcon,\n  FilesIcon,\n  GlobeIcon,\n  PanelRightCloseIcon,\n  TerminalIcon,\n} from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport { Terminal } from '@/components/ai-elements/terminal';\nimport { WorkbenchBrowser } from '@/components/chat/workbench-browser';\nimport { WorkbenchFiles } from '@/components/chat/workbench-files';\nimport { WorkbenchMemory } from '@/components/chat/workbench-memory';\nimport { WorkbenchSchedules } from '@/components/chat/workbench-schedules';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport type { AgentControllerWorkspace } from '@/lib/agent-controller/events';\nimport type { UseAgentControllerChat } from '@/lib/agent-controller/use-agent-controller-chat';\nimport { cn } from '@/lib/utils';\n\n/**\n * The agent workbench — a collapsible right panel that surfaces what the controller\n * agent's Workspace is doing, on three tabs:\n *\n * - **Files** — the agent's filesystem (P3.3: served from `WORKSPACE_ROOT`).\n * - **Terminal** — live shell stdout/stderr, accumulated from `shell_output`.\n * - **Browser** — a live screencast of the agent's Chrome (P3.4: `startScreencast`).\n *\n * It shares the single controller session with `<AgentControllerChat>` (the hook is lifted to\n * the shell), so the panel reflects the same run the user is chatting with.\n */\nexport function WorkbenchPanel({\n  controller,\n  onCollapse,\n}: {\n  controller: UseAgentControllerChat;\n  onCollapse?: () => void;\n}) {\n  const { terminal, workspace, memory } = controller.transcript;\n  const { schedules } = controller;\n\n  return (\n    // Flush to the window edge — the right rail is part of the recessed frame; the chat\n    // floats inset between the two rails (see ChatSwitcher).\n    <div className=\"flex min-h-0 w-[26rem] shrink-0 flex-col bg-sidebar\">\n      <Tabs defaultValue=\"files\" className=\"flex min-h-0 flex-1 flex-col gap-0\">\n        <TabsList\n          variant=\"line\"\n          className=\"w-full justify-start gap-1 rounded-none border-border border-b px-2 py-1\"\n        >\n          <TabsTrigger value=\"files\">\n            <FilesIcon />\n            Files\n          </TabsTrigger>\n          <TabsTrigger value=\"terminal\">\n            <TerminalIcon />\n            Terminal\n          </TabsTrigger>\n          <TabsTrigger value=\"browser\">\n            <GlobeIcon />\n            Browser\n          </TabsTrigger>\n          <TabsTrigger value=\"memory\">\n            <BrainIcon />\n            Memory\n          </TabsTrigger>\n          <TabsTrigger value=\"schedules\">\n            <CalendarClockIcon />\n            Schedules\n          </TabsTrigger>\n          {/* Workspace status dot — reflects the controller workspace lifecycle. */}\n          <WorkspaceStatus workspace={workspace} className=\"ml-auto self-center\" />\n          {/* Collapse control lives in the panel header (not floating over it). */}\n          <button\n            type=\"button\"\n            aria-label=\"Hide workbench\"\n            onClick={onCollapse}\n            className=\"flex size-7 shrink-0 items-center justify-center self-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground\"\n          >\n            <PanelRightCloseIcon className=\"size-4\" />\n          </button>\n        </TabsList>\n\n        <TabsContent value=\"files\" className=\"min-h-0 flex-1 overflow-hidden p-3\">\n          <WorkbenchFiles controller={controller} />\n        </TabsContent>\n        <TabsContent value=\"terminal\" className=\"min-h-0 flex-1 overflow-auto p-4\">\n          {terminal.output ? (\n            <Terminal\n              output={terminal.output}\n              isStreaming={terminal.running}\n              onClear={controller.clearTerminal}\n            />\n          ) : (\n            <PanelPlaceholder>\n              Shell output streams here when the agent runs a command.\n            </PanelPlaceholder>\n          )}\n        </TabsContent>\n        <TabsContent value=\"browser\" className=\"min-h-0 flex-1 overflow-hidden p-3\">\n          <WorkbenchBrowser />\n        </TabsContent>\n        <TabsContent value=\"memory\" className=\"min-h-0 flex-1 overflow-hidden p-3\">\n          <WorkbenchMemory memory={memory} />\n        </TabsContent>\n        <TabsContent value=\"schedules\" className=\"min-h-0 flex-1 overflow-hidden p-3\">\n          <WorkbenchSchedules schedules={schedules} />\n        </TabsContent>\n      </Tabs>\n    </div>\n  );\n}\n\n/**\n * A compact status dot + label for the workspace lifecycle. Green when the\n * agent's workspace is live, red on error, amber (pulsing) while it initializes.\n * Renders nothing until the workspace first reports in, so an idle panel stays quiet.\n */\nfunction WorkspaceStatus({\n  workspace,\n  className,\n}: {\n  workspace: AgentControllerWorkspace | null;\n  className?: string;\n}) {\n  if (!workspace) {\n    return null;\n  }\n  const isReady = workspace.status === 'ready';\n  const isError = workspace.status === 'error';\n  const dot = isReady\n    ? 'bg-emerald-500'\n    : isError\n      ? 'bg-destructive'\n      : 'bg-amber-500 animate-pulse';\n  const label = isReady ? 'Ready' : isError ? 'Error' : workspace.status;\n  return (\n    <span\n      className={cn('flex items-center gap-1.5 text-muted-foreground text-xs', className)}\n      title={workspace.error ?? `Workspace ${workspace.status}`}\n    >\n      <span className={cn('size-1.5 rounded-full', dot)} />\n      <span className=\"capitalize\">{label}</span>\n    </span>\n  );\n}\n\nfunction PanelPlaceholder({ children }: { children: ReactNode }) {\n  return (\n    <div className=\"flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm\">\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chat/workbench-panel.tsx"
    },
    {
      "path": "components/chat/workbench-browser.tsx",
      "content": "'use client';\n\nimport { PlayIcon } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\ntype Status = 'idle' | 'connecting' | 'live' | 'error';\n\n/**\n * Browser tab — a live screencast of the controller agent's Chrome (the\n * `@mastra/browser-viewer` instance), streamed as base64 JPEG frames over SSE\n * from `/api/browser/screencast`.\n *\n * Connecting to that endpoint *launches* the browser server-side (`browser.launch()`),\n * so we do NOT auto-connect on tab open — merely clicking the Browser tab shouldn't\n * spin up Chrome. The view stays idle until the user explicitly starts the live view;\n * only then do we open the EventSource (and the browser launches). Closing the tab /\n * unmounting stops the screencast.\n */\nexport function WorkbenchBrowser() {\n  const [started, setStarted] = useState(false);\n  const [frame, setFrame] = useState<string | null>(null);\n  const [url, setUrl] = useState<string | null>(null);\n  const [status, setStatus] = useState<Status>('idle');\n\n  useEffect(() => {\n    if (!started) return;\n    setStatus('connecting');\n    const es = new EventSource('/api/browser/screencast');\n    es.onmessage = (e) => {\n      try {\n        const msg = JSON.parse(e.data) as { type: string; data?: string; url?: string };\n        if (msg.type === 'frame' && msg.data) {\n          setFrame(`data:image/jpeg;base64,${msg.data}`);\n          setStatus('live');\n        } else if (msg.type === 'url' && msg.url) {\n          setUrl(msg.url);\n        } else if (msg.type === 'error') {\n          setStatus('error');\n        } else if (msg.type === 'stop') {\n          es.close();\n        }\n      } catch {\n        /* ignore malformed frame */\n      }\n    };\n    es.onerror = () => setStatus((s) => (s === 'live' ? s : 'error'));\n    return () => es.close();\n  }, [started]);\n\n  // Idle: nothing has launched. Offer to start the live view on demand.\n  if (!started) {\n    return (\n      <div className=\"flex h-full flex-col items-center justify-center gap-3 px-6 text-center\">\n        <p className=\"text-muted-foreground text-sm\">\n          Watch the agent browse the web here. Starting the live view launches the agent&rsquo;s\n          browser.\n        </p>\n        <button\n          type=\"button\"\n          onClick={() => setStarted(true)}\n          className=\"inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1.5 font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground\"\n        >\n          <PlayIcon className=\"size-3.5\" />\n          Start live view\n        </button>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-full flex-col gap-2\">\n      <div className=\"flex items-center gap-2\">\n        <span\n          className=\"truncate rounded bg-muted px-2 py-1 font-mono text-muted-foreground text-xs\"\n          title={url ?? ''}\n        >\n          {url ?? 'agent browser'}\n        </span>\n      </div>\n      <div className=\"flex min-h-0 flex-1 items-start justify-center overflow-auto rounded-md border border-border bg-muted/30\">\n        {frame ? (\n          // biome-ignore lint/performance/noImgElement: streamed base64 data-URI frame; next/image can't optimize it\n          <img src={frame} alt=\"Live view of the agent's browser\" className=\"w-full\" />\n        ) : (\n          <div className=\"flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm\">\n            {status === 'error'\n              ? \"Browser unavailable — the agent hasn't opened it yet, or Chrome isn't installed.\"\n              : \"Starting the agent's browser…\"}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chat/workbench-browser.tsx"
    },
    {
      "path": "components/chat/workbench-files.tsx",
      "content": "'use client';\n\nimport { XIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport type { BundledLanguage } from 'shiki';\nimport { CodeBlock } from '@/components/ai-elements/code-block';\nimport { FileTree, FileTreeFile, FileTreeFolder } from '@/components/ai-elements/file-tree';\nimport type { UseAgentControllerChat } from '@/lib/agent-controller/use-agent-controller-chat';\nimport { type FileNode, useWorkspaceFiles } from '@/lib/agent-controller/use-workspace';\nimport { cn } from '@/lib/utils';\n\n/** Map a filename to a Shiki language, defaulting to plain text. */\nconst EXT_LANG: Record<string, BundledLanguage> = {\n  ts: 'typescript',\n  tsx: 'tsx',\n  js: 'javascript',\n  jsx: 'jsx',\n  mjs: 'javascript',\n  cjs: 'javascript',\n  json: 'json',\n  md: 'markdown',\n  css: 'css',\n  scss: 'scss',\n  html: 'html',\n  py: 'python',\n  rb: 'ruby',\n  go: 'go',\n  rs: 'rust',\n  sh: 'bash',\n  bash: 'bash',\n  yml: 'yaml',\n  yaml: 'yaml',\n  toml: 'toml',\n  sql: 'sql',\n  java: 'java',\n};\n\nfunction langFor(name: string): BundledLanguage {\n  const ext = name.split('.').pop()?.toLowerCase() ?? '';\n  return EXT_LANG[ext] ?? ('text' as BundledLanguage);\n}\n\n/**\n * Files tab — a live view of the controller agent's workspace (`WORKSPACE_ROOT`),\n * served straight off disk by `/api/workspace/*`. It keeps itself in sync\n * automatically — reloading on mount, polling while the agent is running (so writes\n * appear as they happen), and once more when the run finishes — so there's no manual\n * refresh to think about. Selecting a file loads its text into a CodeBlock.\n */\nexport function WorkbenchFiles({ controller }: { controller: UseAgentControllerChat }) {\n  // Tree loading, streaming-poll and file reads live in the engine (bd h27), so a\n  // second skin can surface the workspace without reimplementing any of it.\n  const { tree, loadingTree, selected, content, loadingFile, selectPath, closeFile } =\n    useWorkspaceFiles({\n      status: controller.status,\n    });\n\n  return (\n    <div className=\"flex h-full flex-col gap-2\">\n      <div className=\"flex items-center justify-between gap-2\">\n        <span className=\"text-muted-foreground text-xs\">Workspace</span>\n        {loadingTree && (\n          <span className=\"text-[10px] text-muted-foreground/70 uppercase tracking-wide\">\n            Syncing…\n          </span>\n        )}\n      </div>\n\n      {tree.length === 0 ? (\n        <div className=\"flex flex-1 items-center justify-center px-6 text-center text-muted-foreground text-sm\">\n          The workspace is empty — files the agent creates appear here.\n        </div>\n      ) : (\n        // When a file is open, cap the tree to a small slice (scrolls if long) so the file\n        // viewer below gets the majority of the height; otherwise the tree fills the panel.\n        <div className={cn('overflow-auto', selected ? 'max-h-[32%] shrink-0' : 'min-h-0 flex-1')}>\n          <FileTree\n            defaultExpanded={new Set(tree.filter((n) => n.type === 'dir').map((n) => n.path))}\n            selectedPath={selected ?? undefined}\n            onSelect={selectPath}\n          >\n            {renderNodes(tree)}\n          </FileTree>\n        </div>\n      )}\n\n      {selected && (\n        <div className=\"flex min-h-0 flex-1 flex-col gap-1 border-border border-t pt-2\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <span className=\"truncate font-mono text-xs\" title={selected}>\n              {selected}\n            </span>\n            <button\n              type=\"button\"\n              aria-label=\"Close file\"\n              onClick={closeFile}\n              className=\"flex size-6 items-center justify-center rounded text-muted-foreground hover:text-foreground\"\n            >\n              <XIcon className=\"size-3.5\" />\n            </button>\n          </div>\n          <div className=\"min-h-0 flex-1 overflow-auto\">\n            {loadingFile ? (\n              <p className=\"text-muted-foreground text-xs\">Loading…</p>\n            ) : content !== null ? (\n              // Soft-wrap long lines so prose/long code flows DOWN instead of scrolling\n              // sideways off the narrow panel. Targets the inner <pre> so the shared\n              // CodeBlock used elsewhere keeps its default (horizontal-scroll) behavior.\n              <CodeBlock\n                code={content}\n                language={langFor(selected)}\n                showLineNumbers\n                className=\"[&_pre]:whitespace-pre-wrap [&_pre]:break-words\"\n              />\n            ) : (\n              <p className=\"text-destructive text-xs\">Could not read this file.</p>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction renderNodes(nodes: FileNode[]): ReactNode {\n  return nodes.map((n) =>\n    n.type === 'dir' ? (\n      <FileTreeFolder key={n.path} path={n.path} name={n.name}>\n        {n.children && renderNodes(n.children)}\n      </FileTreeFolder>\n    ) : (\n      <FileTreeFile key={n.path} path={n.path} name={n.name} />\n    ),\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chat/workbench-files.tsx"
    },
    {
      "path": "components/chat/workbench-memory.tsx",
      "content": "'use client';\n\nimport { BrainIcon, EyeIcon, SparklesIcon, ZapIcon } from 'lucide-react';\nimport type { AgentControllerMemory } from '@/lib/agent-controller/events';\nimport { cn } from '@/lib/utils';\n\n/**\n * Memory tab — a live view of the controller agent's **Observational Memory** (698.20/698.35).\n * A background Observer distills durable facts from the conversation and a Reflector\n * compresses them, so the agent recalls context across chats. This surfaces what that\n * loop is doing, folded from the `om_*` events:\n *\n *  - **Token windows** (`om_status`, fires each run) — how far the conversation has\n *    accumulated toward the next observation, and observations toward the next reflection.\n *  - **Buffer status** — whether the Observer/Reflector are idle or running.\n *  - **Latest observations** — the distilled facts, when the loop surfaces them.\n *  - **Activity** — a rolling log of observe / reflect / activate cycles.\n *\n * The Observer/Reflector run on a background loop, so the lifecycle entries arrive as\n * they happen; `om_status` is the always-present snapshot.\n */\nexport function WorkbenchMemory({ memory }: { memory: AgentControllerMemory | null }) {\n  const status = memory?.status ?? null;\n  const hasContent = !!status || !!memory?.observations || (memory?.activity.length ?? 0) > 0;\n\n  // Nothing recorded yet (fresh user, no run): explain what will appear and when.\n  // (`!memory` in the guard also narrows `memory` to non-null for the render below.)\n  if (!memory || !hasContent) {\n    return (\n      <div className=\"flex h-full flex-col items-center justify-center gap-2 px-6 text-center\">\n        <span className=\"flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary\">\n          <BrainIcon className=\"size-4.5\" />\n        </span>\n        <p className=\"text-pretty text-muted-foreground text-sm\">\n          Observational Memory distills durable facts from your chats and carries them across\n          conversations. The live token windows fill in as you chat; anything it has already learned\n          shows here.\n        </p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-full flex-col gap-3 overflow-auto\">\n      <div className=\"flex items-center gap-2\">\n        <span className=\"flex size-6 items-center justify-center rounded-md bg-primary/10 text-primary\">\n          <BrainIcon className=\"size-3.5\" />\n        </span>\n        <span className=\"font-medium text-sm\">Observational Memory</span>\n      </div>\n\n      {/* Token windows: progress toward the next observe / reflect. Present once a run has\n          emitted an om_status snapshot; hydrated views (facts-only) skip straight to them. */}\n      {status ? (\n        <div className=\"flex flex-col gap-3 rounded-xl border border-border bg-card p-3\">\n          <TokenWindow\n            icon={<EyeIcon className=\"size-3.5\" />}\n            label=\"Messages → observation\"\n            hint=\"Unobserved message tokens; the Observer runs at the threshold.\"\n            tokens={status.messages.tokens}\n            threshold={status.messages.threshold}\n            busy={status.observationBuffer.status !== 'idle'}\n          />\n          <TokenWindow\n            icon={<SparklesIcon className=\"size-3.5\" />}\n            label=\"Observations → reflection\"\n            hint=\"Observation tokens; the Reflector compresses at the threshold.\"\n            tokens={status.observations.tokens}\n            threshold={status.observations.threshold}\n            busy={status.reflectionBuffer.status !== 'idle'}\n          />\n          <div className=\"flex items-center gap-2 text-[11px] text-muted-foreground\">\n            <StatusPill label=\"Observer\" status={status.observationBuffer.status} />\n            <StatusPill label=\"Reflector\" status={status.reflectionBuffer.status} />\n            {status.observationBuffer.chunks > 0 && (\n              <span className=\"tabular-nums\">{status.observationBuffer.chunks} buffered</span>\n            )}\n          </div>\n        </div>\n      ) : (\n        <p className=\"text-muted-foreground text-xs italic\">\n          Send a message to see live memory windows update.\n        </p>\n      )}\n\n      {/* Distilled observations — the facts OM has learned (hydrated on load or streamed live). */}\n      {memory.observations && (\n        <div className=\"rounded-xl border border-border bg-card p-3\">\n          <p className=\"mb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide\">\n            Learned facts\n          </p>\n          <p className=\"whitespace-pre-wrap text-pretty text-sm\">{memory.observations}</p>\n        </div>\n      )}\n\n      {/* Activity log — newest first. */}\n      {memory.activity.length > 0 && (\n        <div className=\"flex flex-col gap-1.5\">\n          <p className=\"font-medium text-muted-foreground text-xs uppercase tracking-wide\">\n            Activity\n          </p>\n          {memory.activity\n            .slice()\n            .reverse()\n            .map((a, i) => (\n              <div\n                // biome-ignore lint/suspicious/noArrayIndexKey: activity is append-only, reversed for display\n                key={`${a.kind}-${i}`}\n                className=\"flex items-start gap-2 text-xs\"\n              >\n                <span\n                  className={cn(\n                    'mt-0.5 flex size-4 shrink-0 items-center justify-center',\n                    a.failed ? 'text-destructive' : 'text-muted-foreground',\n                  )}\n                >\n                  {a.kind === 'observe' ? (\n                    <EyeIcon className=\"size-3.5\" />\n                  ) : a.kind === 'reflect' ? (\n                    <SparklesIcon className=\"size-3.5\" />\n                  ) : (\n                    <ZapIcon className=\"size-3.5\" />\n                  )}\n                </span>\n                <span className={cn('text-pretty', a.failed && 'text-destructive')}>\n                  {a.detail}\n                </span>\n              </div>\n            ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/** One token window: a labelled progress bar toward a threshold, with tabular-nums counts. */\nfunction TokenWindow({\n  icon,\n  label,\n  hint,\n  tokens,\n  threshold,\n  busy,\n}: {\n  icon: React.ReactNode;\n  label: string;\n  hint: string;\n  tokens: number;\n  threshold: number;\n  busy: boolean;\n}) {\n  const pct = threshold > 0 ? Math.min(100, Math.round((tokens / threshold) * 100)) : 0;\n  const reached = threshold > 0 && tokens >= threshold;\n  return (\n    <div className=\"flex flex-col gap-1\" title={hint}>\n      <div className=\"flex items-center justify-between gap-2\">\n        <span className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n          {icon}\n          {label}\n        </span>\n        <span className=\"shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums\">\n          {tokens.toLocaleString()} / {threshold.toLocaleString()}\n        </span>\n      </div>\n      <div className=\"h-1.5 overflow-hidden rounded-full bg-muted\">\n        <div\n          className={cn(\n            'h-full rounded-full transition-[width] duration-500',\n            reached ? 'bg-emerald-500' : busy ? 'animate-pulse bg-amber-500' : 'bg-primary',\n          )}\n          style={{ width: `${pct}%` }}\n        />\n      </div>\n    </div>\n  );\n}\n\n/** Idle/running status chip for the Observer or Reflector. */\nfunction StatusPill({ label, status }: { label: string; status: string }) {\n  const running = status !== 'idle';\n  return (\n    <span\n      className={cn(\n        'rounded-full px-2 py-0.5 font-medium',\n        running ? 'animate-pulse bg-amber-500/10 text-amber-600 dark:text-amber-400' : 'bg-muted',\n      )}\n    >\n      {label}: {running ? status : 'idle'}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/chat/workbench-memory.tsx"
    },
    {
      "path": "components/chat/workbench-schedules.tsx",
      "content": "'use client';\n\nimport { CalendarClockIcon, ClockIcon, PauseIcon, RepeatIcon } from 'lucide-react';\nimport type { AgentControllerSchedule } from '@/lib/agent-controller/events';\nimport { cn } from '@/lib/utils';\n\n/**\n * Schedules tab — the recurring schedules the controller agent has set up via its\n * native `mastra.schedules` tools (698.18). Read-only and agent-driven: the user\n * asks the agent to schedule/cancel something (start_schedule / stop_schedule),\n * and this panel reflects the result — no manual create/pause controls, matching\n * how the rest of the controller surfaces capabilities.\n *\n * The list is fetched from `/api/agent-controller/schedules` on mount and refetched when a\n * run settles (see `useAgentControllerChat.refreshSchedules`), so a schedule the agent\n * just created appears without a reload.\n */\nexport function WorkbenchSchedules({ schedules }: { schedules: AgentControllerSchedule[] }) {\n  if (schedules.length === 0) {\n    return (\n      <div className=\"flex h-full flex-col items-center justify-center gap-2 px-6 text-center\">\n        <span className=\"flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary\">\n          <CalendarClockIcon className=\"size-4.5\" />\n        </span>\n        <p className=\"text-pretty text-muted-foreground text-sm\">\n          No recurring schedules yet. Ask the agent to run something on a timer — “remind me every\n          morning to review the changelog” — and it appears here with its cadence and next run.\n        </p>\n      </div>\n    );\n  }\n\n  // Active first, then paused; within each, soonest next-fire first.\n  const sorted = [...schedules].sort((a, b) => {\n    if (a.status !== b.status) return a.status === 'active' ? -1 : 1;\n    return a.nextFireAt - b.nextFireAt;\n  });\n\n  return (\n    <div className=\"flex h-full flex-col gap-3 overflow-auto\">\n      <div className=\"flex items-center gap-2\">\n        <span className=\"flex size-6 items-center justify-center rounded-md bg-primary/10 text-primary\">\n          <CalendarClockIcon className=\"size-3.5\" />\n        </span>\n        <span className=\"font-medium text-sm\">Schedules</span>\n        <span className=\"ml-auto text-muted-foreground text-xs tabular-nums\">{sorted.length}</span>\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        {sorted.map((s) => (\n          <ScheduleCard key={s.id} schedule={s} />\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/** One schedule: its prompt, cron cadence, status, and next-run time. */\nfunction ScheduleCard({ schedule }: { schedule: AgentControllerSchedule }) {\n  const paused = schedule.status === 'paused';\n  return (\n    // Outer radius (rounded-xl = 12px) − 8px padding → inner chips use rounded-md (6px).\n    <div\n      className={cn(\n        'flex flex-col gap-2 rounded-xl border border-border bg-card p-3 transition-opacity',\n        paused && 'opacity-70',\n      )}\n    >\n      <div className=\"flex items-start justify-between gap-2\">\n        <p className=\"text-pretty font-medium text-sm leading-snug\">\n          {schedule.name || schedule.prompt}\n        </p>\n        <StatusPill paused={paused} />\n      </div>\n      {/* Show the prompt as a secondary line only when a distinct name titled the card. */}\n      {schedule.name && (\n        <p className=\"line-clamp-2 text-pretty text-muted-foreground text-xs\">{schedule.prompt}</p>\n      )}\n      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs\">\n        <span className=\"flex items-center gap-1.5\" title=\"Cron cadence\">\n          <RepeatIcon className=\"size-3.5 shrink-0\" />\n          <code className=\"rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px]\">\n            {schedule.cron}\n          </code>\n        </span>\n        {!paused && schedule.nextFireAt > 0 && (\n          <span className=\"flex items-center gap-1.5\" title=\"Next run\">\n            <ClockIcon className=\"size-3.5 shrink-0\" />\n            <span className=\"tabular-nums\">next {formatRelative(schedule.nextFireAt)}</span>\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/** Active/paused chip. Amber-active reads as \"live\"; muted paused reads as \"held\". */\nfunction StatusPill({ paused }: { paused: boolean }) {\n  return (\n    <span\n      className={cn(\n        'flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 font-medium text-[11px]',\n        paused\n          ? 'bg-muted text-muted-foreground'\n          : 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',\n      )}\n    >\n      {paused ? <PauseIcon className=\"size-3\" /> : <RepeatIcon className=\"size-3\" />}\n      {paused ? 'Paused' : 'Active'}\n    </span>\n  );\n}\n\n/** Compact \"in 3h\" / \"in 2d\" style relative time from an epoch-ms timestamp. */\nfunction formatRelative(epochMs: number): string {\n  const diff = epochMs - Date.now();\n  if (diff <= 0) return 'now';\n  const mins = Math.round(diff / 60_000);\n  if (mins < 60) return `in ${mins}m`;\n  const hours = Math.round(mins / 60);\n  if (hours < 24) return `in ${hours}h`;\n  const days = Math.round(hours / 24);\n  return `in ${days}d`;\n}\n",
      "type": "registry:component",
      "target": "components/chat/workbench-schedules.tsx"
    }
  ],
  "docs": "This kit needs TWO things in your project. Check both:\n\n  1. The RADIX base       ->  npx shadcn@latest init --base radix\n  2. The LUCIDE icon set  ->  \"iconLibrary\": \"lucide\" in components.json\n\nWith both, a fresh install typechecks with 0 errors and `next build` exits 0\n(verified on shadcn CLI 4.16.0 / Next 16.2.6).\n\nIf either is wrong:\n  • Base UI instead of Radix (a bare `init` gives you this — --defaults resolves\n    to --preset=base-nova) -> 14 type errors. This kit's own components port fine\n    either way (the CLI rewrites `asChild` to Base UI's `render`), but the upstream\n    Vercel AI Elements it depends on are Radix-authored and don't survive that.\n  • hugeicons instead of lucide -> 1 type error and a failed build, in shadcn's own\n    ui/spinner.tsx. Fix: set iconLibrary to lucide, then\n    `shadcn add spinner --overwrite`.\n\nThen set MASTRA_SERVER_URL to point at your Mastra server (default\nhttp://localhost:4111). See https://mastra-chat-kit-registry.vercel.app for the full endpoint contract.",
  "type": "registry:block"
}