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-\` (e.g. \`click nav-settings\`) over \`navigate \` so the user sees the cursor travel to the sidebar. - When a step lands on a new page, follow it with \`wait_for \` 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-, settings-agent-edit-, settings-agent-delete-, settings-agent-name-, settings-agent-role-, settings-agent-prompt- Assistant agent picker: assistant-agent (dropdown — click to switch persona) Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-, assistant-thread-rename-, assistant-thread-delete-, 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-, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-, assistant-msg-edit-, assistant-msg-speak- Library page: library-search, library-open-, library-copy-, library-download-, library-delete- Resources page: resources-search, resources-new-name, resources-create, resources-status-, resources-delete- 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--actions (open the kebab first), tenant--suspend, tenant--activate, tenant--deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant--actions, wait_for tenant--suspend, click tenant--suspend. Login page: login-email, login-password, login-submit Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-, notif-dismiss- 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(p: Promise, ms: number, signal: AbortSignal): Promise { return new Promise((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).*(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 = useProviderSettings() const arcadia = useArcadiaClient() const provider = getProvider(settings.providerId) const agents = useAgents() const threads = useThreads() const [status, setStatus] = useState({ kind: "probing" }) const [model, setModel] = useState(() => { if (typeof window === "undefined") return "mock" return localStorage.getItem(STORAGE_KEY) ?? "" }) const [compactNonce, setCompactNonce] = useState(0) const [pendingAction, setPendingAction] = useState(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(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 => { 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 ( setPendingAction(null)} onRequestRetry={(text) => { setPendingAction({ kind: "retry", text }) remount() }} onSwitchThread={switchThread} onNewThread={newConversation} onDeleteThread={removeThread} onRenameThread={renameThread} /> ) } 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(() => { if (typeof window === "undefined") return false return localStorage.getItem(UI_CONTROL_KEY) === "1" }) const [actionLog, setActionLog] = useState(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 1–2 paragraph summary that preserves: user goals, key facts, names, decisions, file paths, code snippets, and unfinished tasks. Use third person ('the user wants X'). No commentary, no preamble, no markdown headings." try { const summary = await completeOneShot( [ { role: "system", content: summarySystem }, { role: "user", content: `Summarize this conversation:\n\n${transcript}`, }, ], { maxTokens: Math.min(800, responseBudget * 2) }, ) const condensed: StoredMessage[] = [ { role: "assistant", content: `📋 **Conversation summary** (older turns compacted)\n\n${summary.trim()}`, }, ] // Snapshot the pre-compact thread so Restore can undo this. snapshotThread(thread.id) // Compact preserves pinned messages verbatim AFTER the summary line. const pinned = thread.pinned .map((idx) => messages[idx]) .filter(Boolean) as StoredMessage[] const next: ThreadMessage[] = [ ...condensed, ...pinned.map((m) => ({ ...m })), ] updateThread(thread.id, { messages: next, pinned: pinned.map((_, i) => condensed.length + i), }) setActionLog(`Compacted ${messages.length} turns → 1 summary.`) onRemount() } catch (e) { setActionLog( `Compact failed: ${e instanceof Error ? e.message : String(e)}`, ) } }, [ compacting, isStreaming, messages, completeOneShot, responseBudget, onRemount, ]) const restoreCompact = useCallback(() => { const snap = loadThreadSnapshot(thread.id) if (!snap) { setActionLog("Nothing to restore.") return } updateThread(thread.id, { messages: snap.messages, pinned: snap.pinned, }) clearThreadSnapshot(thread.id) setActionLog(`Restored ${snap.messages.length} turns.`) onRemount() }, [thread.id, onRemount]) const regenerateLast = useCallback(() => { // Find last user message; remove it + everything after; let parent // re-mount us with that text as a pending retry. let lastUserIdx = -1 for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "user") { lastUserIdx = i break } } if (lastUserIdx === -1) { setActionLog("No user message to regenerate.") return } const text = messages[lastUserIdx].content const truncated = messages.slice(0, lastUserIdx) as ThreadMessage[] updateThread(thread.id, { messages: truncated, pinned: thread.pinned.filter((idx) => idx < lastUserIdx), }) onRequestRetry(text) }, [messages, onRequestRetry, thread.id, thread.pinned]) const handleSendRef = useRef<((t: string) => void) | null>(null) const 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(null) const [editingDraft, setEditingDraft] = useState("") const [speakingIndex, setSpeakingIndex] = useState(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 (2–4 sentences) handing off this conversation to ${target.name} (${target.role}). Cover: what the user is trying to do, key decisions/constraints, what's still open. Address ${target.name} directly. No greetings, no commentary about the handoff process — just the briefing.` try { const briefing = await completeOneShot( [ { role: "system", content: handoffSystem }, { role: "user", content: `Recent turns:\n\n${recent}`, }, ], { maxTokens: 220 }, ) const note = `🤝 **Handoff: ${activeAgent.name} → ${target.name}**\n\n${briefing.trim()}` // Stamp the existing thread messages (preserve their authorship) and // attribute the handoff note itself to the OUTGOING agent. const stamped: ThreadMessage[] = messages.map((m, i) => { const prior = thread.messages[i] if (m.role === "user") return { role: "user", content: m.content } return { role: "assistant", content: m.content, agentId: prior?.agentId ?? activeAgent.id, } }) const next: ThreadMessage[] = [ ...stamped, { role: "assistant", content: note, agentId: activeAgent.id }, ] updateThread(thread.id, { messages: next, agentId: target.id }) saveActiveAgentId(target.id) setActionLog(`Handed off to ${target.name}.`) onRemount() } catch (e) { setActionLog( `Handoff failed: ${e instanceof Error ? e.message : String(e)}`, ) } }, [activeAgent, messages, completeOneShot, thread.id, onRemount], ) const [compareOpen, setCompareOpen] = useState(false) const exportMarkdown = useCallback(() => { const md = buildTranscript() const blob = new Blob([md], { type: "text/markdown;charset=utf-8" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) a.download = `conversation-${stamp}.md` a.click() URL.revokeObjectURL(url) setActionLog("Exported conversation as Markdown.") }, [buildTranscript]) const scrollerRef = useRef(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( '[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 (
{uiControl && showUiControlBanner && (
UI control is on. The assistant can navigate, click, and fill on your behalf. Watch the cursor.
)}
{messages.length === 0 ? ( ) : (
{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 (
{!isUser && msgAgent && ( {agentInitials(msgAgent.name)} )}
{isUser ? "You" : (msgAgent?.name ?? "Assistant")} {!isUser && msgAgent && ( · {msgAgent.role} )} {isPinned && ( )}
{isEditing ? (