import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { RefreshCw, Square, Archive, Loader2, MoreHorizontal, Wand2, Trash2, RotateCcw, ArrowRight, Undo2, Copy, Download, FileText, Pin, Pencil, Volume2, VolumeX, Mic, MicOff, Plus, MessagesSquare, BookmarkPlus, Users, } from "lucide-react" const PROBE_TIMEOUT_MS = 3000 // Static catalog of every data-action id wired into the app. // "Available actions" in the system prompt only lists what's on screen NOW; // this catalog tells the model what exists elsewhere so it can plan // multi-step flows (navigate → wait_for → fill → click) in a single block. const UI_CONTROL_PREFACE = `You are the assistant inside this app and you can drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves. Rules: - Prefer \`click nav-\` (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-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- 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 \`\`\`"` 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, OpenAICompatibleAdapter, listModels, useChat, useCompletion, type LLMAdapter, } from "@crema/llm-ui" import { TypingIndicator } from "@crema/chat-ui" import { CommandBar } from "@crema/aifirst-ui" import { AppShell } from "~/components/layout/app-shell" import { MessageBody } from "~/components/assistant/message-body" import { Button } from "~/components/ui/button" import { buildSystemPrompt, estimateTokens, runActionBlocks, trimMessages, } from "@crema/action-bus" import { useLLMSettings } from "~/lib/llm-settings" import { composeSystemPrompt, loadActiveAgentId, saveActiveAgentId, useAgents, type Agent, } from "~/lib/agents" import { clearThreadSnapshot, createThread, deleteThread, deriveTitleFromFirstMessage, ensureThread, loadThreadSnapshot, saveActiveThreadId, snapshotThread, updateThread, useThreads, type Thread, type ThreadMessage, } from "~/lib/threads" import { addLibraryItem } from "~/lib/library" import { pageTitle } from "~/lib/page-meta" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu" import { ChevronDown } from "lucide-react" import { Avatar, AvatarFallback } from "~/components/ui/avatar" import { Popover, PopoverContent, PopoverTrigger, } from "~/components/ui/popover" export const meta = () => pageTitle("Assistant") const STORAGE_KEY = "crema.assistant.model" const UI_CONTROL_KEY = "crema.assistant.uiControl" type StoredMessage = { role: "user" | "assistant"; content: string } type PendingAction = { kind: "retry"; text: string } | null type Status = | { kind: "probing" } | { kind: "live"; models: string[] } | { kind: "mock"; reason: string } const mockAdapter = new MockLLM({ label: "Mock", delayMs: 18, fallback: "I'm a stand-in for the local model. Start LM Studio at localhost:1234 and reload to swap me out.", responses: [ { matches: (req) => /hello|hi\b|hey/i.test(req.messages.at(-1)?.content ?? ""), response: "Hi — I'm the mock assistant. Try asking me anything; I'll stream a generic reply.", }, { matches: (req) => /(take me to|open|navigate|go to).*(resources|library|settings|activity|assistant|overview|home)/i.test( req.messages.at(-1)?.content ?? "", ), response: [ "On it.\n\n", "```action\n", "navigate /resources\n", "```\n", ], }, ], }) export default function AssistantRoute() { const settings = useLLMSettings() const agents = useAgents() const threads = useThreads() const [status, setStatus] = useState({ 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 probe = useCallback(() => { const ac = new AbortController() setStatus({ kind: "probing" }) withTimeout( listModels({ baseURL: settings.baseURL, signal: ac.signal }), PROBE_TIMEOUT_MS, ac.signal, ) .then((rows) => { const ids = rows.map((m) => m.id) if (ids.length === 0) { setStatus({ kind: "mock", reason: "LM Studio returned no models" }) return } setStatus({ kind: "live", models: ids }) setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0])) }) .catch((err: unknown) => { if ((err as DOMException)?.name === "AbortError") return setStatus({ kind: "mock", reason: err instanceof Error ? err.message : "LM Studio unreachable", }) }) return () => ac.abort() }, [settings.baseURL]) useEffect(() => probe(), [probe]) useEffect(() => { if (model && model !== "mock") localStorage.setItem(STORAGE_KEY, model) }, [model]) const adapter: LLMAdapter = useMemo( () => status.kind === "live" ? new OpenAICompatibleAdapter({ baseURL: settings.baseURL }) : mockAdapter, [status.kind, settings.baseURL], ) const activeModel = status.kind === "live" ? model || status.models[0] : "mock" return ( 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]) const { messages, send, abort, isStreaming, error, reset } = useChat({ system: systemPrompt, initialMessages: thread.messages as StoredMessage[], }) // Persist conversation back into the active thread. // Preserve any agentId already stamped on prior messages, and stamp newly // appended assistant messages with the *currently active* agent. useEffect(() => { if (isStreaming) return const stamped: ThreadMessage[] = messages.map((m, i) => { const prior = thread.messages[i] if (m.role === "user") return { role: "user", content: m.content } const agentId = prior?.agentId ?? activeAgentId return { role: "assistant", content: m.content, agentId } }) updateThread(thread.id, { messages: stamped, ...(thread.title === "New conversation" && messages[0]?.role === "user" ? { title: deriveTitleFromFirstMessage(messages[0].content) } : {}), }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages, isStreaming]) const clearConversation = useCallback(() => { updateThread(thread.id, { messages: [], pinned: [] }) onRemount() }, [thread.id, onRemount]) const { complete: completeOneShot, isLoading: compacting } = useCompletion() const compactConversation = useCallback(async () => { if (compacting || isStreaming || messages.length < 2) return const transcript = messages .map( (m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content.trim()}`, ) .join("\n\n") const summarySystem = "You compress conversations. Output a tight 1–2 paragraph summary that preserves: user goals, key facts, names, decisions, file paths, code snippets, and unfinished tasks. Use third person ('the user wants X'). No commentary, no preamble, no markdown headings." try { const summary = await completeOneShot( [ { role: "system", content: summarySystem }, { role: "user", content: `Summarize this conversation:\n\n${transcript}`, }, ], { maxTokens: Math.min(800, responseBudget * 2) }, ) const condensed: StoredMessage[] = [ { role: "assistant", content: `📋 **Conversation summary** (older turns compacted)\n\n${summary.trim()}`, }, ] // Snapshot the pre-compact thread so Restore can undo this. snapshotThread(thread.id) // Compact preserves pinned messages verbatim AFTER the summary line. const pinned = thread.pinned .map((idx) => messages[idx]) .filter(Boolean) as StoredMessage[] const next: ThreadMessage[] = [ ...condensed, ...pinned.map((m) => ({ ...m })), ] updateThread(thread.id, { messages: next, pinned: pinned.map((_, i) => condensed.length + i), }) setActionLog(`Compacted ${messages.length} turns → 1 summary.`) onRemount() } catch (e) { setActionLog( `Compact failed: ${e instanceof Error ? e.message : String(e)}`, ) } }, [ compacting, isStreaming, messages, completeOneShot, responseBudget, onRemount, ]) const restoreCompact = useCallback(() => { const snap = loadThreadSnapshot(thread.id) if (!snap) { setActionLog("Nothing to restore.") return } updateThread(thread.id, { messages: snap.messages, pinned: snap.pinned, }) clearThreadSnapshot(thread.id) setActionLog(`Restored ${snap.messages.length} turns.`) onRemount() }, [thread.id, onRemount]) const regenerateLast = useCallback(() => { // Find last user message; remove it + everything after; let parent // re-mount us with that text as a pending retry. let lastUserIdx = -1 for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "user") { lastUserIdx = i break } } if (lastUserIdx === -1) { setActionLog("No user message to regenerate.") return } const text = messages[lastUserIdx].content const truncated = messages.slice(0, lastUserIdx) as ThreadMessage[] updateThread(thread.id, { messages: truncated, pinned: thread.pinned.filter((idx) => idx < lastUserIdx), }) onRequestRetry(text) }, [messages, onRequestRetry, thread.id, thread.pinned]) const handleSendRef = useRef<((t: string) => void) | null>(null) const [editingIndex, setEditingIndex] = useState(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 uiControl ? buildSystemPrompt({ path: typeof window !== "undefined" ? window.location.pathname : "", preface: `${UI_CONTROL_PREFACE}\n\n${ activeAgent ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` : "" }`, }) : systemPrompt }, [uiControl, systemPrompt, activeAgent]) const buildTranscript = useCallback(() => { const lines: string[] = [ `# Conversation`, "", activeAgent ? `**Persona:** ${activeAgent.name} — ${activeAgent.role}` : "", `**Date:** ${new Date().toISOString()}`, "", ].filter(Boolean) for (const m of messages) { lines.push(`### ${m.role === "user" ? "User" : "Assistant"}`) lines.push("") lines.push(m.content.trim()) lines.push("") } return lines.join("\n") }, [messages, activeAgent]) const copyMarkdown = useCallback(async () => { try { await navigator.clipboard.writeText(buildTranscript()) setActionLog("Copied conversation to clipboard.") } catch (e) { setActionLog( `Copy failed: ${e instanceof Error ? e.message : String(e)}`, ) } }, [buildTranscript]) const saveToLibrary = useCallback(() => { if (messages.length === 0) { setActionLog("Nothing to save.") return } const md = buildTranscript() const item = addLibraryItem({ kind: "conversation", title: thread.title, content: md, tags: activeAgent ? [activeAgent.role.toLowerCase()] : [], agentName: activeAgent?.name, agentRole: activeAgent?.role, threadId: thread.id, messageCount: messages.length, }) setActionLog(`Saved to Library: "${item.title}".`) }, [messages.length, buildTranscript, thread.title, thread.id, activeAgent]) const handoffToAgent = useCallback( async (target: Agent) => { if (!activeAgent || target.id === activeAgent.id) return const recent = messages .slice(-6) .map( (m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content .trim() .slice(0, 600)}`, ) .join("\n\n") const handoffSystem = `You are ${activeAgent.name} (${activeAgent.role}). Write ONE short paragraph (2–4 sentences) handing off this conversation to ${target.name} (${target.role}). Cover: what the user is trying to do, key decisions/constraints, what's still open. Address ${target.name} directly. No greetings, no commentary about the handoff process — just the briefing.` try { const briefing = await completeOneShot( [ { role: "system", content: handoffSystem }, { role: "user", content: `Recent turns:\n\n${recent}`, }, ], { maxTokens: 220 }, ) const note = `🤝 **Handoff: ${activeAgent.name} → ${target.name}**\n\n${briefing.trim()}` // Stamp the existing thread messages (preserve their authorship) and // attribute the handoff note itself to the OUTGOING agent. const stamped: ThreadMessage[] = messages.map((m, i) => { const prior = thread.messages[i] if (m.role === "user") return { role: "user", content: m.content } return { role: "assistant", content: m.content, agentId: prior?.agentId ?? activeAgent.id, } }) const next: ThreadMessage[] = [ ...stamped, { role: "assistant", content: note, agentId: activeAgent.id }, ] updateThread(thread.id, { messages: next, agentId: target.id }) saveActiveAgentId(target.id) setActionLog(`Handed off to ${target.name}.`) onRemount() } catch (e) { setActionLog( `Handoff failed: ${e instanceof Error ? e.message : String(e)}`, ) } }, [activeAgent, messages, completeOneShot, thread.id, onRemount], ) const [compareOpen, setCompareOpen] = useState(false) const exportMarkdown = useCallback(() => { const md = buildTranscript() const blob = new Blob([md], { type: "text/markdown;charset=utf-8" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) a.download = `conversation-${stamp}.md` a.click() URL.revokeObjectURL(url) setActionLog("Exported conversation as Markdown.") }, [buildTranscript]) const scrollerRef = useRef(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 = uiControl ? buildSystemPrompt({ path: window.location.pathname, preface: `${UI_CONTROL_PREFACE}\n\n${activeAgent ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` : ""}`, }) : systemPrompt const sysTokens = estimateTokens(system) const historyBudget = Math.max( 256, contextTokens - sysTokens - responseBudget, ) const userMsg = { role: "user" as const, content: text } const trimmed = trimMessages([...messages, userMsg], historyBudget) void send(text, { system, messages: trimmed, maxTokens: responseBudget, }) } handleSendRef.current = handleSend // Consume a pending action queued by the parent across remounts (Retry). useEffect(() => { if (!pendingAction) return if (pendingAction.kind === "retry") { handleSend(pendingAction.text) } onPendingActionConsumed() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const usedTokens = useMemo(() => { const system = uiControl ? buildSystemPrompt({ path: typeof window !== "undefined" ? window.location.pathname : "", preface: UI_CONTROL_PREFACE, }) : systemPrompt const sysT = estimateTokens(system) const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0) return sysT + histT }, [messages, uiControl, systemPrompt]) const suggestions = uiControl ? [ "Take me to the resources page", "Type \"hello\" in the appbar search", "Click the notifications button", "Open the account menu", "Show me what's on the screen", ] : [ "What can this app do?", "Draft a release note", "Summarize this week's activity", "Show me a SQL example", ] return (
{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 ? (