// PURPOSE: Global assistant dock — a floating Sparkles FAB that opens a // right-side slide-over chat with an agents-platform agent. The // shell, threading, compose box, and rich-block rendering live // here; the app injects identity (which agent), context (what the // user is looking at), and transport (which closes over auth). // Source of truth for the dock across every Crema webapp. // =========================================================================== // EXPORTS // Component: AgentDock // Types: AgentDockProps, AgentDockTransport, AgentRef, PageContext // =========================================================================== "use client" import { useCallback, useEffect, useRef, useState } from "react" import { Loader2, Maximize2, Plus, Send, Sparkles, X } from "lucide-react" import { MessageBody } from "@crema/agent-ui" /* ------------------------------------------------------------------ */ /* Public types */ /* ------------------------------------------------------------------ */ export type AgentRef = { id: string; name: string } /** Structured page-awareness signal. Travels to the platform as its own * field — never concatenated into the user message — so page content * cannot masquerade as a user instruction. Pass stable identifiers, not * scraped DOM text. */ export type PageContext = { /** App route the user is currently viewing, e.g. `/invoices`. */ route: string /** Optional stable resource identifier — type + id, app-authored. */ resource?: { type: string; id: string } /** Optional short, app-authored label (a stable descriptor). */ label?: string } /** The single platform call the dock needs. The app implements this — * typically by delegating to its `@crema/arcadia-agents-client` instance * — so auth stays closed over inside the transport; the dock never sees * a token. */ export interface AgentDockTransport { chat( agentId: string, req: { user_message: string conversation_id?: string page_context?: PageContext }, ): Promise<{ message: string conversation_id: string citations?: { chunk_id: string }[] }> } export interface AgentDockProps { /** Platform transport — auth is baked in here by the app. */ transport: AgentDockTransport /** Resolve the agent(s) the user can talk to. The dock renders a picker * iff more than one is returned; the first (or `defaultAgentId`) is the * default. A rejected promise disables the dock (renders nothing) — * e.g. a tenant with no personal-agent template. */ resolveAgents: () => Promise /** Preferred default agent id when `resolveAgents` returns several. */ defaultAgentId?: string /** Called on every send. Return the structured page context to attach, * or null to send none. The app decides what counts as "context". */ getPageContext?: () => PageContext | null /** ⤢ handler. Omit to hide the expand button. The app owns what expand * means — navigate to a full chat route, mount its own modal, etc. */ onExpand?: (a: { agentId: string; conversationId: string | null }) => void /** App-computed per-route suppression — the app knows its own routes. */ hidden?: boolean /** localStorage namespace for the open/closed state. */ storageKey?: string } /* ------------------------------------------------------------------ */ /* Internal */ /* ------------------------------------------------------------------ */ type Bubble = | { role: "user"; text: string } | { role: "assistant"; text: string; finished: boolean; error: string | null } const DEFAULT_STORAGE_KEY = "crema.agent-dock.open" function pickDefault(agents: AgentRef[], preferredId?: string): AgentRef | null { if (preferredId) { const match = agents.find((a) => a.id === preferredId) if (match) return match } return agents[0] ?? null } /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ export function AgentDock({ transport, resolveAgents, defaultAgentId, getPageContext, onExpand, hidden = false, storageKey = DEFAULT_STORAGE_KEY, }: AgentDockProps) { const [open, setOpen] = useState(() => { if (typeof window === "undefined") return false return localStorage.getItem(storageKey) === "1" }) useEffect(() => { localStorage.setItem(storageKey, open ? "1" : "0") }, [open, storageKey]) const [agents, setAgents] = useState(null) const [agentId, setAgentId] = useState(null) const [resolveFailed, setResolveFailed] = useState(false) const [bubbles, setBubbles] = useState([]) const [conversationId, setConversationId] = useState(null) const [draft, setDraft] = useState("") const [sending, setSending] = useState(false) const scrollRef = useRef(null) useEffect(() => { let cancelled = false // Clear any prior failure so a retry (e.g. tenant id arrives after // the first render) gets a clean slate — otherwise the flag latches // and the FAB stays hidden forever even once resolution succeeds. setResolveFailed(false) resolveAgents() .then((list) => { if (cancelled) return setAgents(list) setAgentId((cur) => cur ?? pickDefault(list, defaultAgentId)?.id ?? null) }) .catch(() => { if (!cancelled) setResolveFailed(true) }) return () => { cancelled = true } }, [resolveAgents, defaultAgentId]) // Switching agent starts a fresh thread — don't cross-contaminate the // assistant's context across agents. useEffect(() => { setBubbles([]) setConversationId(null) }, [agentId]) useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "auto", }) }, [bubbles]) // `display` is what shows in the user bubble; `wire` is what the agent // receives. They differ only for rich-block action clicks, where the // button label is shown but the action's `value` is sent. const sendMessage = useCallback( async (display: string, wire: string) => { if (!agentId || sending) return const trimmed = wire.trim() if (!trimmed) return setBubbles((bs) => [ ...bs, { role: "user", text: display }, { role: "assistant", text: "", finished: false, error: null }, ]) setSending(true) try { // Non-streaming on purpose: the streaming path injects // stream_options.include_usage, which DeepSeek/Qwen/LM Studio // reject. Dock answers are short; ⤢ opens the full chat surface. const pc = getPageContext?.() ?? undefined const r = await transport.chat(agentId, { user_message: trimmed, conversation_id: conversationId ?? undefined, page_context: pc ?? undefined, }) if (r.conversation_id && !conversationId) setConversationId(r.conversation_id) setBubbles((bs) => { const last = bs[bs.length - 1] if (!last || last.role !== "assistant") return bs const next = [...bs] next[next.length - 1] = { ...last, text: r.message, finished: true } return next }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) setBubbles((bs) => { const last = bs[bs.length - 1] if (!last || last.role !== "assistant") return bs const next = [...bs] next[next.length - 1] = { ...last, error: msg, finished: true } return next }) } finally { setSending(false) } }, [agentId, sending, conversationId, transport, getPageContext], ) const submitDraft = useCallback(() => { const text = draft.trim() if (!text) return setDraft("") void sendMessage(text, text) }, [draft, sendMessage]) function newThread() { if (sending) return setBubbles([]) setConversationId(null) } if (hidden || resolveFailed) return null const currentAgent = agents?.find((a) => a.id === agentId) ?? null const title = currentAgent?.name ?? "Assistant" return ( <> {!open && ( )} {open && (
setOpen(false)} />
{/* Header */}
{title}
{agents && agents.length > 1 && ( )} {onExpand && ( { if (!agentId) return onExpand({ agentId, conversationId }) setOpen(false) }} > )} setOpen(false)} >
{/* Messages */}
{bubbles.length === 0 ? (
{agentId ? "Ask anything. I can see what page you're on." : agents && agents.length === 0 ? "No agents available yet." : "Loading…"}
) : ( bubbles.map((b, i) => b.role === "user" ? (
{b.text}
) : (
{b.error ? ( {b.error} ) : !b.finished && !b.text ? ( Thinking… ) : ( void sendMessage(label, value) } /> )}
), ) )}
{/* Composer */}