import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
  RefreshCw,
  Square,
  Archive,
  Loader2,
  MoreHorizontal,
  Wand2,
  Trash2,
  RotateCcw,
  ArrowRight,
  Undo2,
  Copy,
  Download,
  FileText,
  Pin,
  Pencil,
  Volume2,
  VolumeX,
  Mic,
  MicOff,
  Plus,
  MessagesSquare,
  BookmarkPlus,
  Users,
} from "lucide-react"

const PROBE_TIMEOUT_MS = 3000

// Static catalog of every data-action id wired into the app.
// "Available actions" in the system prompt only lists what's on screen NOW;
// this catalog tells the model what exists elsewhere so it can plan
// multi-step flows (navigate → wait_for → fill → click) in a single block.
const UI_CONTROL_PREFACE = `You are the assistant inside this app and you can drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.

Rules:
- Prefer \`click nav-<page>\` (e.g. \`click nav-settings\`) over \`navigate <path>\` so the user sees the cursor travel to the sidebar.
- When a step lands on a new page, follow it with \`wait_for <id>\` for an element on that page before the next step (it will appear once the page mounts).
- Combine multi-step requests into ONE action block. Quote string values.
- ALWAYS end an action block by returning the user to the Assistant page so the conversation continues there. Append \`click nav-assistant\` as the final step (and \`wait_for assistant-ui-control\` after it) UNLESS the task itself is on the Assistant page.
- Prefer doing over describing. Keep prose to one short sentence; put commands in the block.

Known action ids across the app (use these even if not in "Available actions" — the page may not be mounted yet):

Sidebar / nav: nav-overview, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle
Appbar: appbar-search (input), appbar-scripts, appbar-font-size, appbar-surface, appbar-background, theme-toggle, appbar-notifications, appbar-avatar
Account menu (after click appbar-avatar): avatar-profile (→ /profile), avatar-settings, avatar-help, avatar-signout
Profile page: profile-avatar-upload, profile-avatar-remove, profile-name, profile-email, profile-title, profile-bio, profile-signature, profile-default-agent, profile-save, profile-revert, profile-reset
Overview tiles: home-tile-assistant, home-tile-resources, home-tile-activity, home-tile-library
Settings page: settings-base-url (input, fill with URL), settings-context-tokens (number input), settings-response-budget (number input), settings-system-prompt (textarea), settings-system-prompt-reset, settings-save, settings-test, settings-reset
Settings sub-nav: settings-section-llm, settings-section-agents, settings-section-appearance, settings-section-account, settings-section-about
Agents settings: settings-agent-new, settings-agent-reset, settings-agent-activate-<id>, settings-agent-edit-<id>, settings-agent-delete-<id>, settings-agent-name-<id>, settings-agent-role-<id>, settings-agent-prompt-<id>
Assistant agent picker: assistant-agent (dropdown — click to switch persona)
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
Login page: login-email, login-password, login-submit
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
  fill notif-title "Reminder"
  fill notif-body "Take a 5-minute break"
  fill notif-kind "info"           # info | success | warning | error
  fill notif-href "/library"       # optional, omit if no link
  click notif-create
Use this any time the user asks you to remind them, leave a note, flag something, or queue an item — drop a notification.

Example — User: "Go to settings and set the response cap to 1024" →
"On it.

\`\`\`action
click nav-settings
wait_for settings-response-budget
fill settings-response-budget "1024"
click settings-save
click nav-assistant
wait_for assistant-ui-control
\`\`\`"`


function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise<T> {
  return new Promise<T>((resolve, reject) => {
    const t = setTimeout(() => {
      reject(new Error(`timeout after ${ms}ms`))
    }, ms)
    signal.addEventListener("abort", () => {
      clearTimeout(t)
      reject(new DOMException("Aborted", "AbortError"))
    })
    p.then(
      (v) => {
        clearTimeout(t)
        resolve(v)
      },
      (e) => {
        clearTimeout(t)
        reject(e)
      },
    )
  })
}
import {
  LLMProvider,
  MockLLM,
  OpenAICompatibleAdapter,
  listModels,
  useChat,
  useCompletion,
  type LLMAdapter,
} from "@crema/llm-ui"
import { TypingIndicator } from "@crema/chat-ui"
import { CommandBar } from "@crema/aifirst-ui"

import { AppShell } from "~/components/layout/app-shell"
import { MessageBody } from "~/components/assistant/message-body"
import { Button } from "~/components/ui/button"
import {
  buildSystemPrompt,
  estimateTokens,
  runActionBlocks,
  trimMessages,
} from "@crema/action-bus"
import { useLLMSettings } from "~/lib/llm-settings"
import {
  composeSystemPrompt,
  loadActiveAgentId,
  saveActiveAgentId,
  useAgents,
  type Agent,
} from "~/lib/agents"
import {
  clearThreadSnapshot,
  createThread,
  deleteThread,
  deriveTitleFromFirstMessage,
  ensureThread,
  loadThreadSnapshot,
  saveActiveThreadId,
  snapshotThread,
  updateThread,
  useThreads,
  type Thread,
  type ThreadMessage,
} from "~/lib/threads"
import { addLibraryItem } from "~/lib/library"
import { pageTitle } from "~/lib/page-meta"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import { ChevronDown } from "lucide-react"
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "~/components/ui/popover"

export const meta = () => pageTitle("Assistant")

const STORAGE_KEY = "crema.assistant.model"
const UI_CONTROL_KEY = "crema.assistant.uiControl"
type StoredMessage = { role: "user" | "assistant"; content: string }

type PendingAction = { kind: "retry"; text: string } | null

type Status =
  | { kind: "probing" }
  | { kind: "live"; models: string[] }
  | { kind: "mock"; reason: string }

const mockAdapter = new MockLLM({
  label: "Mock",
  delayMs: 18,
  fallback:
    "I'm a stand-in for the local model. Start LM Studio at localhost:1234 and reload to swap me out.",
  responses: [
    {
      matches: (req) =>
        /hello|hi\b|hey/i.test(req.messages.at(-1)?.content ?? ""),
      response:
        "Hi — I'm the mock assistant. Try asking me anything; I'll stream a generic reply.",
    },
    {
      matches: (req) =>
        /(take me to|open|navigate|go to).*(resources|library|settings|activity|assistant|overview|home)/i.test(
          req.messages.at(-1)?.content ?? "",
        ),
      response: [
        "On it.\n\n",
        "```action\n",
        "navigate /resources\n",
        "```\n",
      ],
    },
  ],
})

export default function AssistantRoute() {
  const settings = useLLMSettings()
  const agents = useAgents()
  const threads = useThreads()
  const [status, setStatus] = useState<Status>({ kind: "probing" })
  const [model, setModel] = useState<string>(() => {
    if (typeof window === "undefined") return "mock"
    return localStorage.getItem(STORAGE_KEY) ?? ""
  })
  const [compactNonce, setCompactNonce] = useState(0)
  const [pendingAction, setPendingAction] = useState<PendingAction>(null)
  const remount = useCallback(() => setCompactNonce((n) => n + 1), [])

  // Ensure at least one thread exists, and resolve the active one.
  const fallbackAgentId =
    loadActiveAgentId() || agents[0]?.id || ""
  const ensured = useMemo(
    () => ensureThread(threads, fallbackAgentId),
    [threads, fallbackAgentId],
  )
  const activeThreadId = ensured.activeId
  const activeThread =
    ensured.threads.find((t) => t.id === activeThreadId) ?? ensured.threads[0]

  const switchThread = useCallback(
    (id: string) => {
      saveActiveThreadId(id)
      remount()
    },
    [remount],
  )

  const newConversation = useCallback(() => {
    const agentId = activeThread?.agentId || fallbackAgentId
    createThread(agentId)
    remount()
  }, [activeThread, fallbackAgentId, remount])

  const removeThread = useCallback(
    (id: string) => {
      deleteThread(id)
      remount()
    },
    [remount],
  )

  const renameThread = useCallback((id: string, title: string) => {
    updateThread(id, { title })
  }, [])

  const probe = useCallback(() => {
    const ac = new AbortController()
    setStatus({ kind: "probing" })
    withTimeout(
      listModels({ baseURL: settings.baseURL, signal: ac.signal }),
      PROBE_TIMEOUT_MS,
      ac.signal,
    )
      .then((rows) => {
        const ids = rows.map((m) => m.id)
        if (ids.length === 0) {
          setStatus({ kind: "mock", reason: "LM Studio returned no models" })
          return
        }
        setStatus({ kind: "live", models: ids })
        setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0]))
      })
      .catch((err: unknown) => {
        if ((err as DOMException)?.name === "AbortError") return
        setStatus({
          kind: "mock",
          reason: err instanceof Error ? err.message : "LM Studio unreachable",
        })
      })
    return () => ac.abort()
  }, [settings.baseURL])

  useEffect(() => probe(), [probe])

  useEffect(() => {
    if (model && model !== "mock") localStorage.setItem(STORAGE_KEY, model)
  }, [model])

  const adapter: LLMAdapter = useMemo(
    () =>
      status.kind === "live"
        ? new OpenAICompatibleAdapter({ baseURL: settings.baseURL })
        : mockAdapter,
    [status.kind, settings.baseURL],
  )

  const activeModel =
    status.kind === "live" ? model || status.models[0] : "mock"

  return (
    <AppShell title="Assistant">
      <LLMProvider adapter={adapter} model={activeModel}>
        <AssistantSurface
          key={`${activeThreadId}-${compactNonce}`}
          thread={activeThread}
          threads={ensured.threads}
          agents={agents}
          status={status}
          model={model}
          onModelChange={setModel}
          contextTokens={settings.contextTokens}
          responseBudget={settings.responseBudget}
          baseURL={settings.baseURL}
          basePrompt={settings.systemPrompt}
          onRetryProbe={probe}
          onRemount={remount}
          pendingAction={pendingAction}
          onPendingActionConsumed={() => setPendingAction(null)}
          onRequestRetry={(text) => {
            setPendingAction({ kind: "retry", text })
            remount()
          }}
          onSwitchThread={switchThread}
          onNewThread={newConversation}
          onDeleteThread={removeThread}
          onRenameThread={renameThread}
        />
      </LLMProvider>
    </AppShell>
  )
}

function AssistantSurface({
  status,
  model,
  onModelChange,
  contextTokens,
  responseBudget,
  baseURL,
  basePrompt,
  onRetryProbe,
  onRemount,
  pendingAction,
  onPendingActionConsumed,
  onRequestRetry,
  thread,
  threads,
  agents,
  onSwitchThread,
  onNewThread,
  onDeleteThread,
  onRenameThread,
}: {
  status: Status
  model: string
  onModelChange: (m: string) => void
  contextTokens: number
  responseBudget: number
  baseURL: string
  basePrompt: string
  onRetryProbe: () => void
  onRemount: () => void
  pendingAction: PendingAction
  onPendingActionConsumed: () => void
  onRequestRetry: (text: string) => void
  thread: Thread
  threads: Thread[]
  agents: Agent[]
  onSwitchThread: (id: string) => void
  onNewThread: () => void
  onDeleteThread: (id: string) => void
  onRenameThread: (id: string, title: string) => void
}) {
  // Active agent comes from the thread itself (per-thread persona).
  const activeAgentId = thread.agentId || agents[0]?.id || ""
  const setActiveAgentId = (id: string) => {
    updateThread(thread.id, { agentId: id })
    saveActiveAgentId(id)
  }
  const activeAgent: Agent | undefined =
    agents.find((a) => a.id === activeAgentId) ?? agents[0]
  const systemPrompt = composeSystemPrompt(basePrompt, activeAgent)
  const [uiControl, setUiControl] = useState<boolean>(() => {
    if (typeof window === "undefined") return false
    return localStorage.getItem(UI_CONTROL_KEY) === "1"
  })
  const [actionLog, setActionLog] = useState<string | null>(null)
  const [showUiControlBanner, setShowUiControlBanner] = useState(false)

  useEffect(() => {
    localStorage.setItem(UI_CONTROL_KEY, uiControl ? "1" : "0")
  }, [uiControl])

  useEffect(() => {
    if (!uiControl) {
      setShowUiControlBanner(false)
      return
    }
    setShowUiControlBanner(true)
    const t = setTimeout(() => setShowUiControlBanner(false), 6000)
    return () => clearTimeout(t)
  }, [uiControl])

  const { messages, send, abort, isStreaming, error, reset } = useChat({
    system: systemPrompt,
    initialMessages: thread.messages as StoredMessage[],
  })

  // Persist conversation back into the active thread.
  // Preserve any agentId already stamped on prior messages, and stamp newly
  // appended assistant messages with the *currently active* agent.
  useEffect(() => {
    if (isStreaming) return
    const stamped: ThreadMessage[] = messages.map((m, i) => {
      const prior = thread.messages[i]
      if (m.role === "user") return { role: "user", content: m.content }
      const agentId = prior?.agentId ?? activeAgentId
      return { role: "assistant", content: m.content, agentId }
    })
    updateThread(thread.id, {
      messages: stamped,
      ...(thread.title === "New conversation" &&
      messages[0]?.role === "user"
        ? { title: deriveTitleFromFirstMessage(messages[0].content) }
        : {}),
    })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [messages, isStreaming])

  const clearConversation = useCallback(() => {
    updateThread(thread.id, { messages: [], pinned: [] })
    onRemount()
  }, [thread.id, onRemount])

  const { complete: completeOneShot, isLoading: compacting } = useCompletion()
  const compactConversation = useCallback(async () => {
    if (compacting || isStreaming || messages.length < 2) return
    const transcript = messages
      .map(
        (m) =>
          `${m.role === "user" ? "User" : "Assistant"}: ${m.content.trim()}`,
      )
      .join("\n\n")
    const summarySystem =
      "You compress conversations. Output a tight 1–2 paragraph summary that preserves: user goals, key facts, names, decisions, file paths, code snippets, and unfinished tasks. Use third person ('the user wants X'). No commentary, no preamble, no markdown headings."
    try {
      const summary = await completeOneShot(
        [
          { role: "system", content: summarySystem },
          {
            role: "user",
            content: `Summarize this conversation:\n\n${transcript}`,
          },
        ],
        { maxTokens: Math.min(800, responseBudget * 2) },
      )
      const condensed: StoredMessage[] = [
        {
          role: "assistant",
          content: `📋 **Conversation summary** (older turns compacted)\n\n${summary.trim()}`,
        },
      ]
      // Snapshot the pre-compact thread so Restore can undo this.
      snapshotThread(thread.id)
      // Compact preserves pinned messages verbatim AFTER the summary line.
      const pinned = thread.pinned
        .map((idx) => messages[idx])
        .filter(Boolean) as StoredMessage[]
      const next: ThreadMessage[] = [
        ...condensed,
        ...pinned.map((m) => ({ ...m })),
      ]
      updateThread(thread.id, {
        messages: next,
        pinned: pinned.map((_, i) => condensed.length + i),
      })
      setActionLog(`Compacted ${messages.length} turns → 1 summary.`)
      onRemount()
    } catch (e) {
      setActionLog(
        `Compact failed: ${e instanceof Error ? e.message : String(e)}`,
      )
    }
  }, [
    compacting,
    isStreaming,
    messages,
    completeOneShot,
    responseBudget,
    onRemount,
  ])

  const restoreCompact = useCallback(() => {
    const snap = loadThreadSnapshot(thread.id)
    if (!snap) {
      setActionLog("Nothing to restore.")
      return
    }
    updateThread(thread.id, {
      messages: snap.messages,
      pinned: snap.pinned,
    })
    clearThreadSnapshot(thread.id)
    setActionLog(`Restored ${snap.messages.length} turns.`)
    onRemount()
  }, [thread.id, onRemount])

  const regenerateLast = useCallback(() => {
    // Find last user message; remove it + everything after; let parent
    // re-mount us with that text as a pending retry.
    let lastUserIdx = -1
    for (let i = messages.length - 1; i >= 0; i--) {
      if (messages[i].role === "user") {
        lastUserIdx = i
        break
      }
    }
    if (lastUserIdx === -1) {
      setActionLog("No user message to regenerate.")
      return
    }
    const text = messages[lastUserIdx].content
    const truncated = messages.slice(0, lastUserIdx) as ThreadMessage[]
    updateThread(thread.id, {
      messages: truncated,
      pinned: thread.pinned.filter((idx) => idx < lastUserIdx),
    })
    onRequestRetry(text)
  }, [messages, onRequestRetry, thread.id, thread.pinned])

  const handleSendRef = useRef<((t: string) => void) | null>(null)
  const [editingIndex, setEditingIndex] = useState<number | null>(null)
  const [editingDraft, setEditingDraft] = useState("")
  const [speakingIndex, setSpeakingIndex] = useState<number | null>(null)
  const pinnedSet = useMemo(() => new Set(thread.pinned), [thread.pinned])

  const togglePin = useCallback(
    (i: number) => {
      const next = pinnedSet.has(i)
        ? thread.pinned.filter((x) => x !== i)
        : [...thread.pinned, i].sort((a, b) => a - b)
      updateThread(thread.id, { pinned: next })
    },
    [pinnedSet, thread.id, thread.pinned],
  )

  const beginEdit = useCallback(
    (i: number, content: string) => {
      setEditingIndex(i)
      setEditingDraft(content)
    },
    [],
  )

  const cancelEdit = useCallback(() => {
    setEditingIndex(null)
    setEditingDraft("")
  }, [])

  const submitEdit = useCallback(() => {
    if (editingIndex === null) return
    const text = editingDraft.trim()
    if (!text) return
    const truncated = messages.slice(0, editingIndex) as ThreadMessage[]
    updateThread(thread.id, {
      messages: truncated,
      pinned: thread.pinned.filter((idx) => idx < editingIndex),
    })
    onRequestRetry(text)
  }, [editingIndex, editingDraft, messages, thread.id, thread.pinned, onRequestRetry])

  const speak = useCallback(
    (i: number, text: string) => {
      if (typeof window === "undefined" || !window.speechSynthesis) return
      if (speakingIndex === i) {
        window.speechSynthesis.cancel()
        setSpeakingIndex(null)
        return
      }
      window.speechSynthesis.cancel()
      const u = new SpeechSynthesisUtterance(text)
      u.onend = () => setSpeakingIndex((cur) => (cur === i ? null : cur))
      u.onerror = () => setSpeakingIndex(null)
      window.speechSynthesis.speak(u)
      setSpeakingIndex(i)
    },
    [speakingIndex],
  )
  const continueLast = useCallback(() => {
    if (isStreaming) return
    handleSendRef.current?.("Please continue your previous reply.")
  }, [isStreaming])

  const [showPromptOpen, setShowPromptOpen] = useState(false)
  const [hasCompactSnapshot, setHasCompactSnapshot] = useState(
    () => !!loadThreadSnapshot(thread.id),
  )
  useEffect(() => {
    const id = setInterval(() => {
      setHasCompactSnapshot(!!loadThreadSnapshot(thread.id))
    }, 1500)
    return () => clearInterval(id)
  }, [thread.id])

  const composedSystemPrompt = useMemo(() => {
    return uiControl
      ? buildSystemPrompt({
          path:
            typeof window !== "undefined" ? window.location.pathname : "",
          preface: `${UI_CONTROL_PREFACE}\n\n${
            activeAgent
              ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
              : ""
          }`,
        })
      : systemPrompt
  }, [uiControl, systemPrompt, activeAgent])

  const buildTranscript = useCallback(() => {
    const lines: string[] = [
      `# Conversation`,
      "",
      activeAgent
        ? `**Persona:** ${activeAgent.name} — ${activeAgent.role}`
        : "",
      `**Date:** ${new Date().toISOString()}`,
      "",
    ].filter(Boolean)
    for (const m of messages) {
      lines.push(`### ${m.role === "user" ? "User" : "Assistant"}`)
      lines.push("")
      lines.push(m.content.trim())
      lines.push("")
    }
    return lines.join("\n")
  }, [messages, activeAgent])

  const copyMarkdown = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(buildTranscript())
      setActionLog("Copied conversation to clipboard.")
    } catch (e) {
      setActionLog(
        `Copy failed: ${e instanceof Error ? e.message : String(e)}`,
      )
    }
  }, [buildTranscript])

  const saveToLibrary = useCallback(() => {
    if (messages.length === 0) {
      setActionLog("Nothing to save.")
      return
    }
    const md = buildTranscript()
    const item = addLibraryItem({
      kind: "conversation",
      title: thread.title,
      content: md,
      tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
      agentName: activeAgent?.name,
      agentRole: activeAgent?.role,
      threadId: thread.id,
      messageCount: messages.length,
    })
    setActionLog(`Saved to Library: "${item.title}".`)
  }, [messages.length, buildTranscript, thread.title, thread.id, activeAgent])

  const handoffToAgent = useCallback(
    async (target: Agent) => {
      if (!activeAgent || target.id === activeAgent.id) return
      const recent = messages
        .slice(-6)
        .map(
          (m) =>
            `${m.role === "user" ? "User" : "Assistant"}: ${m.content
              .trim()
              .slice(0, 600)}`,
        )
        .join("\n\n")
      const handoffSystem = `You are ${activeAgent.name} (${activeAgent.role}). Write ONE short paragraph (2–4 sentences) handing off this conversation to ${target.name} (${target.role}). Cover: what the user is trying to do, key decisions/constraints, what's still open. Address ${target.name} directly. No greetings, no commentary about the handoff process — just the briefing.`
      try {
        const briefing = await completeOneShot(
          [
            { role: "system", content: handoffSystem },
            {
              role: "user",
              content: `Recent turns:\n\n${recent}`,
            },
          ],
          { maxTokens: 220 },
        )
        const note = `🤝 **Handoff: ${activeAgent.name} → ${target.name}**\n\n${briefing.trim()}`
        // Stamp the existing thread messages (preserve their authorship) and
        // attribute the handoff note itself to the OUTGOING agent.
        const stamped: ThreadMessage[] = messages.map((m, i) => {
          const prior = thread.messages[i]
          if (m.role === "user") return { role: "user", content: m.content }
          return {
            role: "assistant",
            content: m.content,
            agentId: prior?.agentId ?? activeAgent.id,
          }
        })
        const next: ThreadMessage[] = [
          ...stamped,
          { role: "assistant", content: note, agentId: activeAgent.id },
        ]
        updateThread(thread.id, { messages: next, agentId: target.id })
        saveActiveAgentId(target.id)
        setActionLog(`Handed off to ${target.name}.`)
        onRemount()
      } catch (e) {
        setActionLog(
          `Handoff failed: ${e instanceof Error ? e.message : String(e)}`,
        )
      }
    },
    [activeAgent, messages, completeOneShot, thread.id, onRemount],
  )

  const [compareOpen, setCompareOpen] = useState(false)

  const exportMarkdown = useCallback(() => {
    const md = buildTranscript()
    const blob = new Blob([md], { type: "text/markdown;charset=utf-8" })
    const url = URL.createObjectURL(blob)
    const a = document.createElement("a")
    a.href = url
    const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
    a.download = `conversation-${stamp}.md`
    a.click()
    URL.revokeObjectURL(url)
    setActionLog("Exported conversation as Markdown.")
  }, [buildTranscript])

  const scrollerRef = useRef<HTMLDivElement>(null)
  const lastContent = messages.at(-1)?.content
  useEffect(() => {
    const el = scrollerRef.current
    if (!el) return
    el.scrollTop = el.scrollHeight
  }, [messages.length, lastContent, isStreaming])

  // Run action blocks when an assistant turn completes (and UI control is on).
  const wasStreaming = useRef(false)
  useEffect(() => {
    if (wasStreaming.current && !isStreaming) {
      const last = messages.at(-1)
      if (uiControl && last?.role === "assistant" && last.content) {
        void runActionBlocks(last.content).then((res) => {
          if (res.ran > 0) {
            setActionLog(`Ran ${res.ran} action block${res.ran > 1 ? "s" : ""}.`)
          } else if (res.errors.length > 0) {
            setActionLog(`Action error: ${res.errors[0]}`)
          }
          // Safety net: always return to the Assistant page once a UI Control
          // sequence finishes, even if the model forgot the trailing nav step.
          if (
            res.ran > 0 &&
            typeof window !== "undefined" &&
            window.location.pathname !== "/assistant"
          ) {
            setTimeout(() => {
              const navAssistant = document.querySelector<HTMLElement>(
                '[data-action="nav-assistant"]',
              )
              if (navAssistant) navAssistant.click()
            }, 600)
          }
        })
      }
    }
    wasStreaming.current = isStreaming
  }, [isStreaming, messages, uiControl])

  const handleSend: (text: string) => void = (text: string) => {
    const system = uiControl
      ? buildSystemPrompt({
          path: window.location.pathname,
          preface: `${UI_CONTROL_PREFACE}\n\n${activeAgent ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` : ""}`,
        })
      : systemPrompt
    const sysTokens = estimateTokens(system)
    const historyBudget = Math.max(
      256,
      contextTokens - sysTokens - responseBudget,
    )
    const userMsg = { role: "user" as const, content: text }
    const trimmed = trimMessages([...messages, userMsg], historyBudget)
    void send(text, {
      system,
      messages: trimmed,
      maxTokens: responseBudget,
    })
  }
  handleSendRef.current = handleSend

  // Consume a pending action queued by the parent across remounts (Retry).
  useEffect(() => {
    if (!pendingAction) return
    if (pendingAction.kind === "retry") {
      handleSend(pendingAction.text)
    }
    onPendingActionConsumed()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  const usedTokens = useMemo(() => {
    const system = uiControl
      ? buildSystemPrompt({
          path: typeof window !== "undefined" ? window.location.pathname : "",
          preface: UI_CONTROL_PREFACE,
        })
      : systemPrompt
    const sysT = estimateTokens(system)
    const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0)
    return sysT + histT
  }, [messages, uiControl, systemPrompt])

  const suggestions = uiControl
    ? [
        "Take me to the resources page",
        "Type \"hello\" in the appbar search",
        "Click the notifications button",
        "Open the account menu",
        "Show me what's on the screen",
      ]
    : [
        "What can this app do?",
        "Draft a release note",
        "Summarize this week's activity",
        "Show me a SQL example",
      ]

  return (
    <div className="flex h-[calc(100svh-3.5rem-3rem)] flex-col gap-4">
      {uiControl && showUiControlBanner && (
        <div className="flex items-start gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground/80">
          <span className="flex-1">
            UI control is on. The assistant can navigate, click, and fill on
            your behalf. Watch the cursor.
          </span>
          <button
            type="button"
            onClick={() => setShowUiControlBanner(false)}
            aria-label="Dismiss"
            className="-my-1 rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
          >
            ✕
          </button>
        </div>
      )}

      <div
        ref={scrollerRef}
        className="flex-1 overflow-y-auto rounded-lg border bg-card/30 p-4"
      >
        {messages.length === 0 ? (
          <EmptyState uiControl={uiControl} />
        ) : (
          <div className="flex flex-col gap-3">
            {messages.map((m, i) => {
              const isPinned = pinnedSet.has(i)
              const isLastUser =
                m.role === "user" &&
                !messages.slice(i + 1).some((x) => x.role === "user")
              const isEditing = editingIndex === i
              const isUser = m.role === "user"
              const msgAgentId =
                !isUser
                  ? thread.messages[i]?.agentId ?? activeAgent?.id
                  : undefined
              const msgAgent = msgAgentId
                ? agents.find((a) => a.id === msgAgentId) ?? activeAgent
                : undefined
              return (
                <div
                  key={i}
                  className={
                    "group flex w-full items-start gap-2 " +
                    (isUser ? "flex-row-reverse" : "flex-row")
                  }
                >
                  {!isUser && msgAgent && (
                    <Avatar
                      className="mt-5 size-7 shrink-0 ring-1 ring-border"
                      title={`${msgAgent.name} — ${msgAgent.role}`}
                    >
                      <AvatarFallback
                        style={{
                          background: agentTint(msgAgent.id),
                          color: "var(--primary-foreground)",
                        }}
                        className="text-[11px] font-semibold"
                      >
                        {agentInitials(msgAgent.name)}
                      </AvatarFallback>
                    </Avatar>
                  )}
                  <div
                    className={
                      "flex max-w-[80%] flex-col " +
                      (isUser ? "items-end" : "items-start")
                    }
                  >
                    <div className="mb-0.5 flex items-center gap-1.5 px-1">
                      <span className="text-xs font-medium text-muted-foreground">
                        {isUser
                          ? "You"
                          : (msgAgent?.name ?? "Assistant")}
                      </span>
                      {!isUser && msgAgent && (
                        <span className="text-[10px] text-muted-foreground/70">
                          · {msgAgent.role}
                        </span>
                      )}
                      {isPinned && (
                        <Pin className="size-3 fill-primary text-primary" />
                      )}
                    </div>
                    {isEditing ? (
                      <div className="w-full rounded-2xl border border-primary/40 bg-background p-2">
                        <textarea
                          value={editingDraft}
                          onChange={(e) => setEditingDraft(e.target.value)}
                          autoFocus
                          rows={3}
                          className="w-full resize-none rounded-md bg-transparent px-2 py-1.5 text-sm leading-relaxed outline-none"
                          onKeyDown={(e) => {
                            if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
                              e.preventDefault()
                              submitEdit()
                            } else if (e.key === "Escape") {
                              e.preventDefault()
                              cancelEdit()
                            }
                          }}
                        />
                        <div className="flex items-center justify-end gap-1 px-1">
                          <Button
                            size="sm"
                            variant="ghost"
                            onClick={cancelEdit}
                          >
                            Cancel
                          </Button>
                          <Button size="sm" onClick={submitEdit}>
                            Save & retry
                          </Button>
                        </div>
                      </div>
                    ) : (
                      <div
                        className={
                          isUser
                            ? "rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-relaxed text-primary-foreground"
                            : "rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground"
                        }
                      >
                        {m.content ? (
                          isUser ? (
                            <span className="whitespace-pre-wrap">
                              {m.content}
                            </span>
                          ) : (
                            <MessageBody content={m.content} />
                          )
                        ) : isStreaming && i === messages.length - 1 ? (
                          "…"
                        ) : null}
                      </div>
                    )}
                    {!isEditing && (
                      <div className="mt-0.5 flex items-center gap-0.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
                        <button
                          type="button"
                          data-action={`assistant-msg-pin-${i}`}
                          onClick={() => togglePin(i)}
                          aria-label={isPinned ? "Unpin" : "Pin"}
                          title={
                            isPinned
                              ? "Unpin (Compact will not preserve)"
                              : "Pin (Compact preserves verbatim)"
                          }
                          className={
                            "rounded p-1 hover:bg-accent " +
                            (isPinned
                              ? "text-primary"
                              : "text-muted-foreground")
                          }
                        >
                          <Pin
                            className={
                              "size-3.5 " + (isPinned ? "fill-current" : "")
                            }
                          />
                        </button>
                        {!isUser && (
                          <button
                            type="button"
                            data-action={`assistant-msg-speak-${i}`}
                            onClick={() => speak(i, m.content)}
                            disabled={!m.content}
                            aria-label={
                              speakingIndex === i ? "Stop reading" : "Read aloud"
                            }
                            title={
                              speakingIndex === i ? "Stop reading" : "Read aloud"
                            }
                            className="rounded p-1 text-muted-foreground hover:bg-accent disabled:opacity-30"
                          >
                            {speakingIndex === i ? (
                              <VolumeX className="size-3.5" />
                            ) : (
                              <Volume2 className="size-3.5" />
                            )}
                          </button>
                        )}
                        {isUser && isLastUser && (
                          <button
                            type="button"
                            data-action={`assistant-msg-edit-${i}`}
                            onClick={() => beginEdit(i, m.content)}
                            disabled={isStreaming}
                            aria-label="Edit message"
                            title="Edit & retry"
                            className="rounded p-1 text-muted-foreground hover:bg-accent disabled:opacity-30"
                          >
                            <Pencil className="size-3.5" />
                          </button>
                        )}
                      </div>
                    )}
                  </div>
                </div>
              )
            })}
            {isStreaming && messages.at(-1)?.role !== "assistant" && (
              <TypingIndicator />
            )}
          </div>
        )}
      </div>

      {actionLog && (
        <div className="rounded-lg border bg-card/40 px-3 py-2 text-xs text-muted-foreground">
          {actionLog}
        </div>
      )}

      {error && (
        <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
          {error.message}
        </div>
      )}

      <div className="relative">
        <CommandBar
          chips={suggestions}
          position="bottom"
          placeholder={
            uiControl ? "Ask me to do something…" : "Ask the assistant…"
          }
          onSubmit={handleSend}
          onChipSelect={handleSend}
        />
        <VoiceInputButton onTranscript={(t) => handleSend(t)} />
      </div>

      <div className="flex flex-wrap items-center gap-2 rounded-lg border bg-card/50 px-3 py-2">
        <StatusDot status={status} />
        {status.kind === "probing" && (
          <span className="text-xs text-muted-foreground">Probing…</span>
        )}
        {status.kind === "live" && (
          <>
            <KindChip kind={endpointKind(baseURL)} />
            <HostChip baseURL={baseURL} />
          </>
        )}
        {status.kind === "mock" && (
          <span title={status.reason}>
            <KindChip kind="mock" />
          </span>
        )}
        <ContextGauge
          used={usedTokens}
          total={contextTokens}
          warnAt={contextTokens - responseBudget}
        />
        <ThreadsPicker
          thread={thread}
          threads={threads}
          onSwitch={onSwitchThread}
          onNew={onNewThread}
          onDelete={onDeleteThread}
          onRename={onRenameThread}
        />
        <DropdownMenu>
          <DropdownMenuTrigger
            data-action="assistant-agent"
            className="inline-flex items-center gap-2 rounded-md py-1 pl-1 pr-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            title={
              activeAgent
                ? `${activeAgent.name} — ${activeAgent.role}`
                : "Pick a persona"
            }
          >
            <Avatar className="size-8 ring-2 ring-primary/30">
              <AvatarFallback
                style={{
                  background: agentTint(activeAgent?.id ?? ""),
                  color: "var(--primary-foreground)",
                }}
                className="text-xs font-semibold"
              >
                {agentInitials(activeAgent?.name)}
              </AvatarFallback>
            </Avatar>
            <span className="flex flex-col leading-tight">
              <span className="text-sm font-medium">
                {activeAgent?.name ?? "Agent"}
              </span>
              <span className="text-[10px] text-muted-foreground">
                {activeAgent?.role}
              </span>
            </span>
            <ChevronDown className="size-3.5 opacity-60" />
          </DropdownMenuTrigger>
          <DropdownMenuContent
            align="start"
            side="top"
            sideOffset={6}
            className="max-h-80 w-72 overflow-y-auto"
          >
            <div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
              Switch persona
            </div>
            {agents.map((a) => (
              <DropdownMenuItem
                key={a.id}
                onClick={() => setActiveAgentId(a.id)}
                data-state={activeAgentId === a.id ? "checked" : undefined}
                className="flex items-center gap-2.5"
              >
                <Avatar className="size-7">
                  <AvatarFallback
                    style={{
                      background: agentTint(a.id),
                      color: "var(--primary-foreground)",
                    }}
                    className="text-[11px] font-semibold"
                  >
                    {agentInitials(a.name)}
                  </AvatarFallback>
                </Avatar>
                <span className="flex min-w-0 flex-col">
                  <span className="truncate font-medium">{a.name}</span>
                  <span className="truncate text-xs text-muted-foreground">
                    {a.role}
                  </span>
                </span>
              </DropdownMenuItem>
            ))}
            {agents.filter((a) => a.id !== activeAgentId).length > 0 &&
              messages.length > 0 && (
                <>
                  <div className="my-1 h-px bg-border" />
                  <div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
                    Hand off (with briefing)
                  </div>
                  {agents
                    .filter((a) => a.id !== activeAgentId)
                    .map((a) => (
                      <DropdownMenuItem
                        key={`handoff-${a.id}`}
                        data-action={`assistant-handoff-${a.id}`}
                        onClick={() => void handoffToAgent(a)}
                        className="flex items-center gap-2.5"
                      >
                        <span className="inline-flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
                          <ArrowRight className="size-3.5" />
                        </span>
                        <span className="flex min-w-0 flex-col">
                          <span className="truncate text-sm font-medium">
                            Hand off to {a.name}
                          </span>
                          <span className="truncate text-xs text-muted-foreground">
                            {a.role}
                          </span>
                        </span>
                      </DropdownMenuItem>
                    ))}
                </>
              )}
          </DropdownMenuContent>
        </DropdownMenu>
        <div className="ml-auto flex items-center gap-2">
          {status.kind === "live" ? (
            <DropdownMenu>
              <DropdownMenuTrigger
                data-action="assistant-model"
                className="inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <span className="max-w-[18ch] truncate">
                  {model || status.models[0]}
                </span>
                <ChevronDown className="size-3.5 opacity-60" />
              </DropdownMenuTrigger>
              <DropdownMenuContent
                align="end"
                side="top"
                sideOffset={6}
                className="max-h-64 overflow-y-auto"
              >
                {status.models.map((m) => (
                  <DropdownMenuItem
                    key={m}
                    onClick={() => onModelChange(m)}
                    data-state={
                      (model || status.models[0]) === m ? "checked" : undefined
                    }
                  >
                    {m}
                  </DropdownMenuItem>
                ))}
              </DropdownMenuContent>
            </DropdownMenu>
          ) : (
            <span className="rounded-md border bg-muted px-2 py-1 text-xs">
              mock
            </span>
          )}
          {uiControl && (
            <span
              className="hidden rounded-full border border-primary/40 bg-primary/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary md:inline"
              title="UI Control is on"
            >
              UI · ON
            </span>
          )}
          {isStreaming && (
            <Button
              data-action="assistant-stop"
              variant="outline"
              size="sm"
              onClick={abort}
            >
              <Square className="size-3 fill-current" /> Stop
            </Button>
          )}
          <Popover>
            <PopoverTrigger
              render={
                <Button
                  data-action="assistant-actions"
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Conversation actions"
                  title="Actions"
                >
                  <MoreHorizontal />
                </Button>
              }
            />
            <PopoverContent align="end" side="top" className="w-80 p-2">
              {/* UI Control toggle — full width */}
              <button
                type="button"
                data-action="assistant-ui-control"
                onClick={() => setUiControl((v) => !v)}
                className="mb-2 flex w-full items-center gap-2 rounded-md border bg-card/40 px-2.5 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
                title={
                  uiControl
                    ? "UI Control on — assistant can drive the UI"
                    : "UI Control off — chat-only"
                }
              >
                <Wand2 className="size-4" />
                <span className="flex flex-1 flex-col leading-tight">
                  <span className="font-medium">UI Control</span>
                  <span className="text-[11px] text-muted-foreground">
                    {uiControl ? "On" : "Off"}
                  </span>
                </span>
                <span
                  className={
                    "size-2 rounded-full " +
                    (uiControl ? "bg-primary" : "bg-muted-foreground/40")
                  }
                  aria-hidden
                />
              </button>

              <SectionLabel>Conversation</SectionLabel>
              <div className="grid grid-cols-2 gap-1">
                <ToolTile
                  data-action="assistant-regenerate"
                  onClick={regenerateLast}
                  disabled={
                    isStreaming ||
                    !messages.some((m) => m.role === "user")
                  }
                  icon={<RotateCcw className="size-4" />}
                  label="Regenerate"
                  title="Re-run the most recent prompt"
                />
                <ToolTile
                  data-action="assistant-continue"
                  onClick={continueLast}
                  disabled={isStreaming || messages.length === 0}
                  icon={<ArrowRight className="size-4" />}
                  label="Continue"
                  title="Ask the model to keep going"
                />
                <ToolTile
                  data-action="assistant-compact"
                  onClick={() => void compactConversation()}
                  disabled={compacting || isStreaming || messages.length < 2}
                  icon={
                    compacting ? (
                      <Loader2 className="size-4 animate-spin" />
                    ) : (
                      <Archive className="size-4" />
                    )
                  }
                  label="Compact"
                  title="Summarize older turns to free context"
                />
                <ToolTile
                  data-action="assistant-restore-compact"
                  onClick={restoreCompact}
                  disabled={!hasCompactSnapshot}
                  icon={<Undo2 className="size-4" />}
                  label="Restore"
                  title={
                    hasCompactSnapshot
                      ? "Undo the most recent compact"
                      : "No snapshot available"
                  }
                />
              </div>

              <SectionLabel>Share</SectionLabel>
              <div className="grid grid-cols-2 gap-1">
                <ToolTile
                  data-action="assistant-copy-md"
                  onClick={() => void copyMarkdown()}
                  disabled={messages.length === 0}
                  icon={<Copy className="size-4" />}
                  label="Copy MD"
                  title="Copy conversation as Markdown"
                />
                <ToolTile
                  data-action="assistant-export-md"
                  onClick={exportMarkdown}
                  disabled={messages.length === 0}
                  icon={<Download className="size-4" />}
                  label="Export MD"
                  title="Download a .md file"
                />
                <ToolTile
                  data-action="assistant-save-library"
                  onClick={saveToLibrary}
                  disabled={messages.length === 0}
                  icon={<BookmarkPlus className="size-4" />}
                  label="Save to Library"
                  title="Snapshot this conversation"
                />
                <ToolTile
                  data-action="assistant-show-prompt"
                  onClick={() => setShowPromptOpen(true)}
                  icon={<FileText className="size-4" />}
                  label="Show prompt"
                  title="Preview the system prompt"
                />
              </div>

              <SectionLabel>Multi-agent</SectionLabel>
              <div className="grid grid-cols-2 gap-1">
                <ToolTile
                  data-action="assistant-compare"
                  onClick={() => setCompareOpen(true)}
                  disabled={
                    agents.length < 2 ||
                    !messages.some((m) => m.role === "user")
                  }
                  icon={<Users className="size-4" />}
                  label="Compare"
                  title="Run the last prompt through several personas"
                />
                {status.kind === "mock" && (
                  <ToolTile
                    data-action="assistant-retry-probe"
                    onClick={onRetryProbe}
                    icon={<RefreshCw className="size-4" />}
                    label="Reconnect"
                    title="Probe the LLM endpoint again"
                  />
                )}
              </div>

              <div className="my-2 h-px bg-border" />
              <ToolTile
                data-action="assistant-clear"
                onClick={clearConversation}
                disabled={messages.length === 0}
                icon={<Trash2 className="size-4" />}
                label="Clear conversation"
                title="Wipe history and start fresh"
                destructive
                fullWidth
              />
            </PopoverContent>
          </Popover>
        </div>
      </div>

      {showPromptOpen && (
        <SystemPromptDialog
          prompt={composedSystemPrompt}
          uiControl={uiControl}
          onClose={() => setShowPromptOpen(false)}
        />
      )}

      {compareOpen && (
        <CompareDialog
          agents={agents}
          basePrompt={basePrompt}
          messages={messages as ThreadMessage[]}
          responseBudget={responseBudget}
          onClose={() => setCompareOpen(false)}
          onAppend={(agentName, content) => {
            const speaker = agents.find((a) => a.name === agentName)
            const note = `🪞 **${agentName} says:**\n\n${content}`
            const stamped: ThreadMessage[] = messages.map((m, i) => {
              const prior = thread.messages[i]
              if (m.role === "user") return { role: "user", content: m.content }
              return {
                role: "assistant",
                content: m.content,
                agentId: prior?.agentId ?? activeAgent?.id,
              }
            })
            const next: ThreadMessage[] = [
              ...stamped,
              {
                role: "assistant",
                content: note,
                agentId: speaker?.id ?? activeAgent?.id,
              },
            ]
            updateThread(thread.id, { messages: next })
            setActionLog(`Appended ${agentName}'s reply to the thread.`)
            setCompareOpen(false)
            onRemount()
          }}
        />
      )}
    </div>
  )
}

function CompareDialog({
  agents,
  basePrompt,
  messages,
  responseBudget,
  onClose,
  onAppend,
}: {
  agents: Agent[]
  basePrompt: string
  messages: ThreadMessage[]
  responseBudget: number
  onClose: () => void
  onAppend: (agentName: string, content: string) => void
}) {
  const lastUser = [...messages].reverse().find((m) => m.role === "user")
  const [selected, setSelected] = useState<Set<string>>(
    () => new Set(agents.map((a) => a.id)),
  )
  const [prompt, setPrompt] = useState(lastUser?.content ?? "")
  const [results, setResults] = useState<
    Record<string, { state: "pending" | "ok" | "fail"; content: string }>
  >({})
  const [running, setRunning] = useState(false)
  const { complete } = useCompletion()

  const toggle = (id: string) => {
    const next = new Set(selected)
    if (next.has(id)) next.delete(id)
    else next.add(id)
    setSelected(next)
  }

  const run = async () => {
    if (!prompt.trim() || selected.size === 0) return
    setRunning(true)
    const initial: typeof results = {}
    for (const id of selected) {
      initial[id] = { state: "pending", content: "" }
    }
    setResults(initial)
    await Promise.all(
      Array.from(selected).map(async (id) => {
        const a = agents.find((x) => x.id === id)
        if (!a) return
        const sys = `${basePrompt}\n\nActive persona: ${a.name} — ${a.role}\n${a.prompt}`
        try {
          const out = await complete(
            [
              { role: "system", content: sys },
              { role: "user", content: prompt },
            ],
            { maxTokens: responseBudget },
          )
          setResults((cur) => ({
            ...cur,
            [id]: { state: "ok", content: out },
          }))
        } catch (e) {
          setResults((cur) => ({
            ...cur,
            [id]: {
              state: "fail",
              content: e instanceof Error ? e.message : String(e),
            },
          }))
        }
      }),
    )
    setRunning(false)
  }

  return (
    <div
      role="dialog"
      aria-modal="true"
      onClick={onClose}
      className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm"
    >
      <div
        onClick={(e) => e.stopPropagation()}
        className="flex max-h-[88vh] w-full max-w-5xl flex-col rounded-lg border bg-card shadow-lg"
      >
        <div className="flex items-center gap-2 border-b px-4 py-3">
          <Users className="size-4 text-muted-foreground" />
          <div className="flex flex-1 flex-col">
            <span className="text-sm font-semibold">Compare across agents</span>
            <span className="text-xs text-muted-foreground">
              Run the same prompt through several personas in parallel.
            </span>
          </div>
          <Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close">
            <Square className="size-3" />
          </Button>
        </div>

        <div className="flex flex-col gap-3 border-b px-4 py-3">
          <textarea
            value={prompt}
            onChange={(e) => setPrompt(e.target.value)}
            rows={3}
            className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring/40"
            placeholder="Prompt to send to each selected agent…"
          />
          <div className="flex flex-wrap items-center gap-2">
            {agents.map((a) => {
              const on = selected.has(a.id)
              return (
                <button
                  key={a.id}
                  type="button"
                  onClick={() => toggle(a.id)}
                  className={
                    "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs " +
                    (on
                      ? "border-primary/40 bg-primary/10 text-primary"
                      : "border-border text-muted-foreground hover:bg-accent hover:text-foreground")
                  }
                >
                  <span
                    className="inline-block size-2 rounded-full"
                    style={{ background: agentTint(a.id) }}
                    aria-hidden
                  />
                  <span>{a.name}</span>
                  <span className="opacity-60">· {a.role}</span>
                </button>
              )
            })}
            <div className="ml-auto flex items-center gap-2">
              <span className="text-xs text-muted-foreground">
                {selected.size} selected
              </span>
              <Button
                onClick={() => void run()}
                disabled={running || selected.size === 0 || !prompt.trim()}
                size="sm"
              >
                {running ? (
                  <Loader2 className="size-3.5 animate-spin" />
                ) : (
                  <ArrowRight className="size-3.5" />
                )}
                Run
              </Button>
            </div>
          </div>
        </div>

        <div className="grid flex-1 grid-cols-1 gap-3 overflow-auto p-4 md:grid-cols-2 lg:grid-cols-3">
          {Array.from(selected).map((id) => {
            const a = agents.find((x) => x.id === id)
            if (!a) return null
            const r = results[id]
            return (
              <div
                key={id}
                className="flex flex-col rounded-lg border bg-background"
              >
                <div className="flex items-center gap-2 border-b px-3 py-2">
                  <span
                    className="size-2 rounded-full"
                    style={{ background: agentTint(a.id) }}
                    aria-hidden
                  />
                  <div className="flex flex-1 flex-col leading-tight">
                    <span className="text-sm font-medium">{a.name}</span>
                    <span className="text-[11px] text-muted-foreground">
                      {a.role}
                    </span>
                  </div>
                  {r?.state === "ok" && (
                    <Button
                      size="sm"
                      variant="ghost"
                      onClick={() => onAppend(a.name, r.content)}
                      title="Append to thread"
                    >
                      <ArrowRight className="size-3.5" /> Use
                    </Button>
                  )}
                </div>
                <div className="max-h-72 min-h-32 overflow-auto p-3 text-xs leading-relaxed">
                  {!r ? (
                    <span className="text-muted-foreground">
                      Click Run to generate.
                    </span>
                  ) : r.state === "pending" ? (
                    <span className="inline-flex items-center gap-1 text-muted-foreground">
                      <Loader2 className="size-3 animate-spin" /> Thinking…
                    </span>
                  ) : r.state === "fail" ? (
                    <span className="text-destructive">Error: {r.content}</span>
                  ) : (
                    <pre className="whitespace-pre-wrap font-sans">
                      {r.content}
                    </pre>
                  )}
                </div>
              </div>
            )
          })}
        </div>
      </div>
    </div>
  )
}

function SystemPromptDialog({
  prompt,
  uiControl,
  onClose,
}: {
  prompt: string
  uiControl: boolean
  onClose: () => void
}) {
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(prompt)
    } catch {
      /* ignore */
    }
  }
  return (
    <div
      role="dialog"
      aria-modal="true"
      onClick={onClose}
      className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm"
    >
      <div
        onClick={(e) => e.stopPropagation()}
        className="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border bg-card shadow-lg"
      >
        <div className="flex items-center gap-2 border-b px-4 py-3">
          <FileText className="size-4 text-muted-foreground" />
          <div className="flex flex-1 flex-col">
            <span className="text-sm font-semibold">System prompt</span>
            <span className="text-xs text-muted-foreground">
              {uiControl
                ? "UI Control on — includes action DSL + persona + live actions"
                : "Base prompt + active persona"}
            </span>
          </div>
          <Button variant="ghost" size="sm" onClick={copy}>
            <Copy className="size-3.5" /> Copy
          </Button>
          <Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close">
            <Square className="size-3" />
          </Button>
        </div>
        <pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed">
          {prompt}
        </pre>
      </div>
    </div>
  )
}

function ContextGauge({
  used,
  total,
  warnAt,
}: {
  used: number
  total: number
  warnAt: number
}) {
  const pct = total > 0 ? Math.min(1, used / total) : 0
  const warn = used > warnAt
  const fmt = (n: number) =>
    n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : `${n}`
  return (
    <span
      className="inline-flex min-w-0 items-center gap-2 text-xs tabular-nums text-muted-foreground"
      title={`${used} / ${total} tokens used (${Math.round(pct * 100)}%). Response capped at ${total - warnAt}.`}
    >
      <span
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={total}
        aria-valuenow={used}
        aria-label="Context tokens used"
        className="relative h-1.5 w-32 overflow-hidden rounded-full bg-muted"
      >
        <span
          className={`absolute inset-y-0 left-0 rounded-full transition-[width] duration-200 ease-out ${
            warn ? "bg-amber-500" : "bg-primary"
          }`}
          style={{ width: `${pct * 100}%` }}
        />
      </span>
      <span className={warn ? "text-amber-600 dark:text-amber-300" : ""}>
        {fmt(used)}/{fmt(total)}
      </span>
    </span>
  )
}

function SectionLabel({ children }: { children: React.ReactNode }) {
  return (
    <div className="mt-2 mb-1 px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
      {children}
    </div>
  )
}

function ToolTile({
  icon,
  label,
  title,
  onClick,
  disabled,
  destructive,
  fullWidth,
  ...rest
}: {
  icon: React.ReactNode
  label: string
  title: string
  onClick: () => void
  disabled?: boolean
  destructive?: boolean
  fullWidth?: boolean
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "title">) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      title={title}
      className={
        "flex items-center gap-2 rounded-md border border-transparent px-2 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 " +
        (fullWidth ? "w-full " : "") +
        (destructive
          ? "text-destructive hover:bg-destructive/10"
          : "hover:bg-accent hover:text-accent-foreground")
      }
      {...rest}
    >
      <span className="shrink-0">{icon}</span>
      <span className="truncate font-medium">{label}</span>
    </button>
  )
}

type SpeechRecognitionLike = {
  start: () => void
  stop: () => void
  abort: () => void
  onresult: ((e: { results: { 0: { transcript: string } }[] }) => void) | null
  onerror: ((e: unknown) => void) | null
  onend: (() => void) | null
  lang: string
  interimResults: boolean
  continuous: boolean
}

function getSpeechRecognition(): (new () => SpeechRecognitionLike) | null {
  if (typeof window === "undefined") return null
  const w = window as unknown as {
    SpeechRecognition?: new () => SpeechRecognitionLike
    webkitSpeechRecognition?: new () => SpeechRecognitionLike
  }
  return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null
}

function VoiceInputButton({
  onTranscript,
}: {
  onTranscript: (text: string) => void
}) {
  const Ctor = getSpeechRecognition()
  const [listening, setListening] = useState(false)
  const recRef = useRef<SpeechRecognitionLike | null>(null)

  if (!Ctor) return null

  const start = () => {
    try {
      const rec = new Ctor()
      rec.lang = navigator.language || "en-US"
      rec.interimResults = false
      rec.continuous = false
      rec.onresult = (e) => {
        const text = e.results[0]?.[0]?.transcript ?? ""
        if (text.trim()) onTranscript(text.trim())
      }
      rec.onerror = () => setListening(false)
      rec.onend = () => setListening(false)
      recRef.current = rec
      rec.start()
      setListening(true)
    } catch {
      setListening(false)
    }
  }
  const stop = () => {
    recRef.current?.stop()
    setListening(false)
  }

  return (
    <button
      type="button"
      data-action="assistant-voice"
      onClick={() => (listening ? stop() : start())}
      aria-label={listening ? "Stop listening" : "Voice input"}
      title={listening ? "Stop listening" : "Voice input"}
      className={
        "absolute right-4 top-1/2 z-30 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-full border bg-background shadow-sm transition-colors " +
        (listening
          ? "border-destructive/50 bg-destructive/10 text-destructive animate-pulse"
          : "text-muted-foreground hover:bg-accent hover:text-foreground")
      }
    >
      {listening ? <MicOff className="size-4" /> : <Mic className="size-4" />}
    </button>
  )
}

function ThreadsPicker({
  thread,
  threads,
  onSwitch,
  onNew,
  onDelete,
  onRename,
}: {
  thread: Thread
  threads: Thread[]
  onSwitch: (id: string) => void
  onNew: () => void
  onDelete: (id: string) => void
  onRename: (id: string, title: string) => void
}) {
  const sorted = [...threads].sort((a, b) => b.updatedAt - a.updatedAt)
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        data-action="assistant-thread"
        className="inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        title="Conversations"
      >
        <MessagesSquare className="size-3.5 opacity-70" />
        <span className="max-w-[14ch] truncate">{thread.title}</span>
        <ChevronDown className="size-3.5 opacity-60" />
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="start"
        side="top"
        sideOffset={6}
        className="max-h-80 w-72 overflow-y-auto p-1"
      >
        <button
          type="button"
          data-action="assistant-thread-new"
          onClick={onNew}
          className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
        >
          <Plus className="size-4" />
          <span className="font-medium">New conversation</span>
        </button>
        <div className="my-1 h-px bg-border" />
        {sorted.map((t) => {
          const isActive = t.id === thread.id
          return (
            <div
              key={t.id}
              className={
                "group flex items-center gap-1 rounded-md " +
                (isActive ? "bg-accent" : "")
              }
            >
              <button
                type="button"
                data-action={`assistant-thread-switch-${t.id}`}
                onClick={() => onSwitch(t.id)}
                className="flex flex-1 flex-col items-start gap-0.5 px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
              >
                <span className="line-clamp-1 font-medium">{t.title}</span>
                <span className="text-[11px] text-muted-foreground">
                  {t.messages.length} msg ·{" "}
                  {new Date(t.updatedAt).toLocaleDateString()}
                </span>
              </button>
              <button
                type="button"
                data-action={`assistant-thread-rename-${t.id}`}
                onClick={() => {
                  const next = window.prompt("Rename conversation", t.title)
                  if (next && next.trim()) onRename(t.id, next.trim())
                }}
                className="rounded p-1 text-muted-foreground opacity-0 hover:bg-background hover:text-foreground group-hover:opacity-100"
                title="Rename"
                aria-label="Rename"
              >
                <Pencil className="size-3.5" />
              </button>
              <button
                type="button"
                data-action={`assistant-thread-delete-${t.id}`}
                onClick={() => {
                  if (threads.length <= 1) return
                  if (window.confirm(`Delete "${t.title}"?`)) onDelete(t.id)
                }}
                disabled={threads.length <= 1}
                className="rounded p-1 text-muted-foreground opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
                title="Delete"
                aria-label="Delete"
              >
                <Trash2 className="size-3.5" />
              </button>
            </div>
          )
        })}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

function agentInitials(name: string | undefined): string {
  if (!name) return "?"
  const words = name.trim().split(/\s+/).filter(Boolean)
  if (words.length === 0) return "?"
  if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
  return (words[0][0] + words[words.length - 1][0]).toUpperCase()
}

function agentTint(id: string): string {
  // Deterministic OKLCH hue from the id so each persona gets a stable colour.
  let hash = 0
  for (let i = 0; i < id.length; i++) {
    hash = (hash * 31 + id.charCodeAt(i)) | 0
  }
  const hue = ((hash % 360) + 360) % 360
  return `oklch(0.55 0.18 ${hue})`
}

function StatusDot({ status }: { status: Status }) {
  const cls =
    status.kind === "probing"
      ? "bg-muted-foreground/40"
      : status.kind === "live"
        ? "bg-emerald-500 ring-2 ring-emerald-500/25 shadow-[0_0_6px_var(--color-emerald-500)] animate-pulse"
        : "bg-rose-500 ring-2 ring-rose-500/25"
  const label =
    status.kind === "live"
      ? "Connected"
      : status.kind === "mock"
        ? "Disconnected — using mock"
        : "Probing"
  return (
    <span
      role="status"
      aria-label={label}
      title={label}
      className={`size-2.5 shrink-0 rounded-full ${cls}`}
      data-status={status.kind}
    />
  )
}

function endpointKind(url: string): "local" | "api" {
  try {
    const u = new URL(url)
    if (
      u.hostname === "localhost" ||
      u.hostname === "127.0.0.1" ||
      u.hostname === "0.0.0.0" ||
      u.hostname.endsWith(".local")
    ) {
      return "local"
    }
    return "api"
  } catch {
    return "api"
  }
}

function KindChip({ kind }: { kind: "local" | "api" | "mock" }) {
  const styles: Record<typeof kind, string> = {
    local:
      "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
    api: "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300",
    mock: "border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-300",
  }
  const label = kind === "local" ? "Local" : kind === "api" ? "API" : "Mock"
  return (
    <span
      className={`rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${styles[kind]}`}
    >
      {label}
    </span>
  )
}

function HostChip({ baseURL }: { baseURL: string }) {
  let host = baseURL.replace(/^https?:\/\//, "")
  try {
    const u = new URL(baseURL)
    host = `${u.hostname}${u.port ? `:${u.port}` : ""}`
  } catch {
    /* fall back to stripped string */
  }
  return (
    <span
      className="rounded-full border bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground"
      title={baseURL}
    >
      {host}
    </span>
  )
}

function EmptyState({ uiControl }: { uiControl: boolean }) {
  return (
    <div className="flex h-full flex-col items-center justify-center gap-2 text-center">
      <p
        style={{ fontFamily: "var(--font-ai-prose)" }}
        className="text-lg text-foreground"
      >
        {uiControl ? "Tell me what you'd like to do." : "How can I help?"}
      </p>
      <p className="text-sm text-muted-foreground">
        {uiControl
          ? "I can navigate, click buttons, and fill forms. Watch the cursor."
          : "Pick a suggestion below or type a question."}
      </p>
    </div>
  )
}
