feat: lib-agent-dock-ui — global assistant dock
@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) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
*.log
|
||||
47
README.md
Normal file
47
README.md
Normal file
@@ -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"
|
||||
|
||||
<AgentDock
|
||||
transport={{ chat }}
|
||||
resolveAgents={() => 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).
|
||||
424
src/index.tsx
Normal file
424
src/index.tsx
Normal file
@@ -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<AgentRef[]>
|
||||
/** 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<boolean>(() => {
|
||||
if (typeof window === "undefined") return false
|
||||
return localStorage.getItem(storageKey) === "1"
|
||||
})
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey, open ? "1" : "0")
|
||||
}, [open, storageKey])
|
||||
|
||||
const [agents, setAgents] = useState<AgentRef[] | null>(null)
|
||||
const [agentId, setAgentId] = useState<string | null>(null)
|
||||
const [resolveFailed, setResolveFailed] = useState(false)
|
||||
const [bubbles, setBubbles] = useState<Bubble[]>([])
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
const [sending, setSending] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(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 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open assistant"
|
||||
data-action="assistant-dock-toggle"
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-4 right-4 z-40 flex size-14 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] shadow-lg transition hover:opacity-90"
|
||||
>
|
||||
<Sparkles className="size-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Assistant"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sheet-content"
|
||||
className="absolute right-0 top-0 flex h-full w-full flex-col bg-[var(--background)] shadow-2xl sm:w-[480px] md:w-[560px]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--border)] p-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Sparkles className="size-4 shrink-0 text-[var(--primary)]" />
|
||||
<span className="truncate font-semibold" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{agents && agents.length > 1 && (
|
||||
<select
|
||||
aria-label="Switch agent"
|
||||
value={agentId ?? ""}
|
||||
onChange={(e) => setAgentId(e.target.value || null)}
|
||||
disabled={sending}
|
||||
data-action="assistant-dock-agent-select"
|
||||
className="max-w-[160px] rounded border border-[var(--border)] bg-transparent px-2 py-1 text-sm"
|
||||
>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<IconButton
|
||||
label="New chat"
|
||||
dataAction="assistant-dock-new"
|
||||
disabled={sending || bubbles.length === 0}
|
||||
onClick={newThread}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</IconButton>
|
||||
{onExpand && (
|
||||
<IconButton
|
||||
label="Open in full page"
|
||||
dataAction="assistant-dock-expand"
|
||||
disabled={!agentId}
|
||||
onClick={() => {
|
||||
if (!agentId) return
|
||||
onExpand({ agentId, conversationId })
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label="Close assistant"
|
||||
dataAction="assistant-dock-close"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{bubbles.length === 0 ? (
|
||||
<div className="pt-8 text-center text-sm text-[var(--foreground)]/80">
|
||||
{agentId
|
||||
? "Ask anything. I can see what page you're on."
|
||||
: agents && agents.length === 0
|
||||
? "No agents available yet."
|
||||
: "Loading…"}
|
||||
</div>
|
||||
) : (
|
||||
bubbles.map((b, i) =>
|
||||
b.role === "user" ? (
|
||||
<div
|
||||
key={i}
|
||||
className="ml-auto max-w-[85%] whitespace-pre-wrap rounded-lg bg-[var(--chat-user-bg)] px-3 py-2 text-sm text-[var(--chat-user-fg)]"
|
||||
>
|
||||
{b.text}
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="mr-auto flex max-w-[95%] gap-2">
|
||||
<Sparkles className="mt-2 size-3.5 shrink-0 text-[var(--primary)]" />
|
||||
<div className="min-w-0 flex-1 rounded-lg border border-[var(--chat-assistant-border)] bg-[var(--chat-assistant-bg)] px-3 py-2 text-[15px] leading-[1.65] text-[var(--chat-assistant-fg)]">
|
||||
{b.error ? (
|
||||
<span className="text-[var(--destructive)]">
|
||||
{b.error}
|
||||
</span>
|
||||
) : !b.finished && !b.text ? (
|
||||
<span className="inline-flex items-center gap-2 text-[var(--foreground)]/70">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Thinking…
|
||||
</span>
|
||||
) : (
|
||||
<MessageBody
|
||||
content={b.text}
|
||||
onAction={(label, value) =>
|
||||
void sendMessage(label, value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="shrink-0 border-t border-[var(--border)] p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submitDraft()
|
||||
}
|
||||
}}
|
||||
placeholder={agentId ? "Message…" : "No agent selected"}
|
||||
disabled={!agentId}
|
||||
rows={1}
|
||||
data-action="assistant-dock-input"
|
||||
className="max-h-32 min-h-9 flex-1 resize-none rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm outline-none focus:border-[var(--primary)]/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={sending ? "Sending" : "Send"}
|
||||
onClick={submitDraft}
|
||||
disabled={sending || !draft.trim() || !agentId}
|
||||
data-action="assistant-dock-send"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] transition hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
dataAction,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
dataAction: string
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-action={dataAction}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="flex size-8 items-center justify-center rounded-md text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-40"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user