From 05d2195fe8c98361d1adc2991a9f7525fc3e1f73 Mon Sep 17 00:00:00 2001 From: jules Date: Wed, 20 May 2026 15:06:02 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20lib-agent-dock-ui=20=E2=80=94=20global?= =?UTF-8?q?=20assistant=20dock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @crema/agent-dock-ui — a floating Sparkles FAB that opens a right-side slide-over chat with an agents-platform agent. Extracted from skyai-finance (the canonical dock) and made app-agnostic. The shell, threading, compose box, and rich-block rendering (via @crema/agent-ui's MessageBody) live here; the app injects identity (resolveAgents), context (getPageContext → structured PageContext), and transport (auth closed over inside it — the dock never sees a token). Self-contained: raw slide-over + buttons styled with theme tokens, no app-local shadcn dependency. Page context travels as a structured field, never concatenated into the message. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + README.md | 47 ++++++ src/index.tsx | 424 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 src/index.tsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5d0c3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..4278d97 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# lib-agent-dock-ui + +`@crema/agent-dock-ui` — the global **assistant dock**: a floating +Sparkles FAB that opens a right-side slide-over chat with an +agents-platform agent. + +The dock runs **in-process** in the host app (it needs DOM access for +rich-block rendering and the action bus), so it ships as a lib, not an +iframe or a CDN widget. The shell, threading, compose box, and rich-block +rendering live here; the app injects: + +- **identity** — `resolveAgents()` returns the agent(s) the user can talk + to. One → no picker; many → a dropdown. +- **context** — `getPageContext()` returns a structured `PageContext` + (`{route, resource?, label?}`). It travels to the platform as its own + field, never concatenated into the message, so page content can't + masquerade as a user instruction. +- **transport** — `AgentDockTransport.chat()`. Auth is closed over inside + the transport (the app builds it from its `@crema/arcadia-agents-client` + instance); the dock never sees a token. + +## Usage + +```tsx +import { AgentDock } from "@crema/agent-dock-ui" +import { chat, listAgents } from "~/lib/api/arcadia" + + listAgents(tenantId, { archived: "false" })} + defaultAgentId={profile.defaultAgentId} + getPageContext={() => ({ route: location.pathname })} + onExpand={({ agentId }) => navigate(`/agents/${agentId}/chat`)} + hidden={isChatRoute} +/> +``` + +Mount once, near the app root. `hidden` is app-computed per-route +suppression. `onExpand` is optional — omit it to hide the ⤢ button. + +## Dependencies + +- `@crema/agent-ui` — `MessageBody` (rich-block rendering) +- `react`, `lucide-react` + +Theme contract: consumes the `--chat-user-bg/-fg` and +`--chat-assistant-bg/-fg/-border` tokens (every Crema theme defines them). diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..2ca5ae4 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,424 @@ +// 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 + 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 */} +
+
+