Files
arcadia-admin/app/routes/assistant.tsx
jules e5cd85fff3 Add 5 more admin tools + inline write confirmation flow
New tools in admin-tools.ts:
- list_audit_log({limit?}) — recent audit entries (terse: actor, action,
  target, timestamp). Hits /api/v1/admin/audit-log.
- get_platform_stats() — aggregate counts (tenants by status + by plan),
  composed locally from list_tenants until arcadia exposes a real stats
  endpoint.
- list_users({limit?}) — users in the currently-selected tenant via
  /api/v1/users.
- suspend_tenant({slug}) — write tool, suspends a tenant by slug.
- activate_tenant({slug}) — write tool, restores a suspended/deactivated
  tenant.

Inline write confirmation:
- New ConfirmCard component renders below the assistant message that
  proposed a write. Shows tool(args) and Confirm/Deny buttons.
- classifyCalls() splits LLM tool calls into reads/writes. Auto-loop
  runs reads immediately; for any writes, holds them in pendingConfirm
  state instead of dispatching.
- On Confirm: runs writes with allowWrites:true, prepends prior read
  results, continueChat to produce the final answer.
- On Deny: synthesises tool-result messages telling the model the user
  declined; continueChat so it can acknowledge.
- Arcadia-knowledge primer updated to tell the model the user sees an
  inline confirm card automatically — it shouldn't ask in prose first.

Wired into both /ai and /assistant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:16:41 +10:00

2233 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
You can both answer factual questions about the current state (use the "Admin context" block below) and 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-tenants, 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>
Tenants page: tenants-refresh, tenants-search (input), tenants-create. Per-row (use the tenant's slug — see the "tenants" surface in Admin context for available slugs): tenant-<slug>-actions (open the kebab first), tenant-<slug>-suspend, tenant-<slug>-activate, tenant-<slug>-deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant-<slug>-actions, wait_for tenant-<slug>-suspend, click tenant-<slug>-suspend.
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
\`\`\`"`
import { formatAdminContextForPrompt } from "~/lib/admin-context"
import {
buildDenialMessages,
classifyCalls,
getOpenAITools,
runLLMToolCalls,
} from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
import { useArcadiaClient } from "@crema/arcadia-client"
import { ConfirmCard } from "~/components/assistant/confirm-card"
import type { ToolCall } from "@crema/llm-ui"
/**
* Always includes domain knowledge + tools + admin context + persona.
* Adds the UI-control DSL/action catalog only when uiControl is on (those rules
* are about driving the cursor, irrelevant when the assistant is in plain Q&A).
*/
function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean): string {
const persona = activeAgent
? `Active persona: ${activeAgent.name}${activeAgent.role}\n${activeAgent.prompt}`
: ""
const ctx = formatAdminContextForPrompt()
const parts = [
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
ARCADIA_KNOWLEDGE,
persona,
ctx,
]
if (uiControl) parts.push(UI_CONTROL_PREFACE)
return parts.filter(Boolean).join("\n\n")
}
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])
// Always-on admin system prompt — domain primer + tools + persona, regardless
// of UI Control. Per-call `send(text, {system})` overrides may not always be
// honored across hook re-renders, so anchor it at construction too.
const constructorSystemPrompt = buildSystemPrompt({
path: typeof window !== "undefined" ? window.location.pathname : "",
preface: buildAdminPreface(activeAgent, uiControl),
includeActions: uiControl,
})
const { messages, send, continueChat, abort, isStreaming, error, reset } = useChat({
system: constructorSystemPrompt,
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 }
if (m.role === "tool") {
return {
role: "tool",
content: m.content,
toolCallId: m.toolCallId,
name: m.name,
}
}
const agentId = prior?.agentId ?? activeAgentId
return {
role: "assistant",
content: m.content,
agentId,
...(m.toolCalls ? { toolCalls: m.toolCalls } : {}),
}
})
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 12 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 arcadia = useArcadiaClient()
const toolIterationsRef = useRef(0)
const processedTurnRef = useRef(-1)
const prevStreamingRef = useRef(isStreaming)
const MAX_TOOL_ITERATIONS = 3
// Auto tool-loop using native function calls. Reads run automatically;
// writes are held in `pendingConfirm` until the operator clicks Confirm
// or Deny in the inline ConfirmCard.
const [pendingConfirm, setPendingConfirm] = useState<{
afterIndex: number
writes: ToolCall[]
readMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
} | null>(null)
const [confirmBusy, setConfirmBusy] = useState(false)
useEffect(() => {
const justFinished = prevStreamingRef.current && !isStreaming
prevStreamingRef.current = isStreaming
if (!justFinished) return
const lastIdx = messages.length - 1
if (lastIdx < 0) return
const last = messages[lastIdx]
if (last.role !== "assistant") return
if (processedTurnRef.current === lastIdx) return
processedTurnRef.current = lastIdx
const calls = last.toolCalls ?? []
if (calls.length === 0) {
toolIterationsRef.current = 0
return
}
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) {
console.warn("[admin-tools] tool-iteration cap reached, dropping calls", calls)
return
}
toolIterationsRef.current += 1
void (async () => {
const { reads, writes } = classifyCalls(calls)
const { toolMessages: readMsgs } =
reads.length > 0
? await runLLMToolCalls(reads, { arcadia })
: { toolMessages: [] }
if (writes.length > 0) {
setPendingConfirm({ afterIndex: lastIdx, writes, readMessages: readMsgs })
return
}
void continueChat(readMsgs, {
system: constructorSystemPrompt,
tools: getOpenAITools(),
maxTokens: responseBudget,
})
})()
}, [messages, isStreaming, arcadia, continueChat, constructorSystemPrompt, responseBudget])
const onConfirmWrites = useCallback(async () => {
if (!pendingConfirm) return
setConfirmBusy(true)
try {
const { toolMessages: writeMsgs } = await runLLMToolCalls(
pendingConfirm.writes,
{ arcadia },
{ allowWrites: true },
)
void continueChat([...pendingConfirm.readMessages, ...writeMsgs], {
system: constructorSystemPrompt,
tools: getOpenAITools(),
maxTokens: responseBudget,
})
} finally {
setPendingConfirm(null)
setConfirmBusy(false)
}
}, [pendingConfirm, arcadia, continueChat, constructorSystemPrompt, responseBudget])
const onDenyWrites = useCallback(() => {
if (!pendingConfirm) return
const denials = buildDenialMessages(pendingConfirm.writes)
void continueChat([...pendingConfirm.readMessages, ...denials], {
system: constructorSystemPrompt,
tools: getOpenAITools(),
maxTokens: responseBudget,
})
setPendingConfirm(null)
}, [pendingConfirm, continueChat, constructorSystemPrompt, responseBudget])
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 buildSystemPrompt({
path: typeof window !== "undefined" ? window.location.pathname : "",
preface: buildAdminPreface(activeAgent, uiControl),
includeActions: uiControl,
})
}, [uiControl, 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 (24 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 = buildSystemPrompt({
path: window.location.pathname,
preface: buildAdminPreface(activeAgent, uiControl),
includeActions: uiControl,
})
void send(text, {
system,
tools: getOpenAITools(),
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 = buildSystemPrompt({
path: typeof window !== "undefined" ? window.location.pathname : "",
preface: buildAdminPreface(activeAgent, uiControl),
includeActions: uiControl,
})
const sysT = estimateTokens(system)
const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0)
return sysT + histT
}, [messages, uiControl, systemPrompt, activeAgent])
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={
m.role === "tool"
? "text-sm leading-relaxed"
: 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.role === "tool" ? (
<MessageBody content={m.content} isToolResult />
) : m.content || (m.role === "assistant" && m.toolCalls?.length) ? (
isUser ? (
<span className="whitespace-pre-wrap">
{m.content}
</span>
) : (
<MessageBody content={m.content} toolCalls={m.toolCalls} />
)
) : 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>
{pendingConfirm?.afterIndex === i && (
<ConfirmCard
calls={pendingConfirm.writes}
onConfirm={onConfirmWrites}
onDeny={onDenyWrites}
busy={confirmBusy}
/>
)}
</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>
)
}