Files
arcadia-admin/app/routes/assistant.tsx
jules a286b9cdce aifirst: lift context/agents/tools runtime to lib-aifirst-ui
The mechanism (context surface registry, persona storage + hooks, tool
parser/dispatcher) is now generic and lives in @crema/aifirst-ui/{context,
agents,tools}. This template keeps only the arcadia-shaped configuration:

- agents.ts — owns DEFAULT_AGENTS + legacy/retired migration sets, calls
  configureAgents() at module load, re-exports the runtime
- admin-tools.ts — keeps the 19 arcadia tool definitions, binds the
  runtime via createToolRuntime(TOOLS), re-exports the bound functions
- admin-context.ts — deleted; 18 routes now import directly from
  @crema/aifirst-ui/context

Routes that import from ~/lib/agents and ~/lib/admin-tools are unchanged
(wrapper modules preserve the existing import surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:18:48 +10:00

2318 lines
84 KiB
TypeScript
Raw Permalink 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.
// Rich-output protocol: typed fenced blocks the chat renderer turns into UI
// from @crema/*-ui. The system prompt only carries a thin INDEX (kind →
// one-line purpose) — full schemas live in app/lib/block-schemas.ts and are
// fetched on demand via the get_block_schema tool. Adding a new block kind
// = edit block-schemas.ts + the renderer; no prompt edit required.
import { blockIndexForPrompt } from "~/lib/block-schemas"
const RICH_OUTPUT_PREFACE = blockIndexForPrompt()
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-search, 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.
Search page (/search — manage arcadia-search tenants and corpora): search-refresh, search-restart (with confirm), search-new-tenant, search-new-corpus, corpora-search (input). Per-tenant chip: tenant-<id>-delete. Per-corpus row (id is "<tenant>-<corpus>"): corpus-<tenant>-<corpus>-actions (kebab), corpus-<tenant>-<corpus>-rebuild, corpus-<tenant>-<corpus>-edit, corpus-<tenant>-<corpus>-delete. New-tenant dialog: tenant-form-id (input), tenant-form-cancel, tenant-form-save. New/edit-corpus dialog: corpus-form-tenant (select, only when creating), corpus-form-config (textarea, JSON), corpus-form-cancel, corpus-form-save. Recipe to rebuild a corpus: click nav-search, wait_for search-refresh, click corpus-<tenant>-<corpus>-actions, wait_for corpus-<tenant>-<corpus>-rebuild, click corpus-<tenant>-<corpus>-rebuild. (NOTE: prefer the \`rebuild_search_corpus\` tool over UI-driving for rebuilds — it's atomic and gives a structured result; UI-drive only when the user explicitly wants to see it happen.)
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 { formatContextForPrompt } from "@crema/aifirst-ui/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 = formatContextForPrompt()
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.",
"Two retrieval surfaces exist for documentation/knowledge: `search_docs` (browser-side, BM25 over the bundled arcadia docs — fast, always available, small corpus) and `search_kb` (server-side, BM25 over arcadia-search — `docs` (arcadia parity), `operator-tools` (arcadia-search + arcadia-admin admin docs), `files` (uploaded files), plus any custom corpora the operator adds via /search). For questions about the bundled arcadia docs either is fine; prefer `search_kb` for richer hits or for content outside the bundled docs (uploaded files, the admin tooling itself, tenant-specific knowledge). If unsure what corpora exist, call `list_search_corpora`. When `search_kb` returns a chunk_id you want to expand, call `read_chunk(chunk_id, corpus)`. When the operator says results look stale or after they've uploaded new files, call `rebuild_search_corpus(tenant, corpus)`.",
RICH_OUTPUT_PREFACE,
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,
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 {
buildAdapter,
getProvider,
useSettings as useProviderSettings,
} from "@crema/llm-providers-ui"
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).*(tenants|users|library|settings|activity|assistant|overview|home)/i.test(
req.messages.at(-1)?.content ?? "",
),
response: [
"On it.\n\n",
"```action\n",
"navigate /tenants\n",
"```\n",
],
},
],
})
export default function AssistantRoute() {
const settings = useProviderSettings()
const arcadia = useArcadiaClient()
const provider = getProvider(settings.providerId)
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 [adapter, setAdapter] = useState<LLMAdapter>(mockAdapter)
// When the user switches providers in /settings, follow.
useEffect(() => {
if (settings.model) setModel(settings.model)
}, [settings.providerId, settings.model])
const probe = useCallback(() => {
const ac = new AbortController()
setStatus({ kind: "probing" })
const resolveSecret = async (name: string): Promise<string> => {
const res = await arcadia.GET<{ data: { value: string } }>(
`/api/v1/secrets/${encodeURIComponent(name)}`,
)
return res.data.value
}
const arcadiaBaseURL =
(import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
const arcadiaTenantId =
(import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
const arcadiaAuthToken =
typeof window !== "undefined"
? sessionStorage.getItem("arcadia_access_token") ?? undefined
: undefined
;(async () => {
try {
const a = await buildAdapter({
settings,
resolveSecret,
arcadiaBaseURL,
arcadiaAuthToken,
arcadiaTenantId,
})
setAdapter(a)
} catch {
setAdapter(mockAdapter)
}
// Anthropic has no /v1/models endpoint — use the catalog defaults.
if (provider.transport === "anthropic") {
const ids = provider.defaultModels.length
? provider.defaultModels
: ["claude-opus-4-7"]
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
return
}
const baseURL = settings.baseURL || provider.baseURL
let apiKey: string | undefined
if (provider.requiresKey && settings.secretName) {
try {
apiKey = await resolveSecret(settings.secretName)
} catch {}
}
try {
const rows = await withTimeout(
listModels({ baseURL, apiKey, signal: ac.signal }),
PROBE_TIMEOUT_MS,
ac.signal,
)
const ids = rows.map((m) => m.id)
if (ids.length === 0) {
setStatus({ kind: "mock", reason: "endpoint returned no models" })
return
}
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
} catch (err: unknown) {
if ((err as DOMException)?.name === "AbortError") return
if (provider.defaultModels.length) {
setStatus({ kind: "live", models: provider.defaultModels })
setModel((cur) =>
cur && provider.defaultModels.includes(cur)
? cur
: settings.model || provider.defaultModels[0],
)
} else {
setStatus({
kind: "mock",
reason: err instanceof Error ? err.message : "endpoint unreachable",
})
}
}
})()
return () => ac.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
arcadia,
settings.providerId,
settings.baseURL,
settings.secretName,
settings.mode,
settings.model,
provider.transport,
provider.baseURL,
provider.requiresKey,
])
useEffect(() => probe(), [probe])
useEffect(() => {
if (model && model !== "mock") localStorage.setItem(STORAGE_KEY, model)
}, [model])
const activeModel =
status.kind === "live" ? model || status.models[0] : "mock"
return (
<AppShell>
<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 || provider.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>
)
}