Files
lib-agent-dock-ui/src/index.tsx
jules 777951ca9c fix: clear resolveFailed on retry so the FAB recovers
resolveAgents() throwing once (e.g. tenant id not ready on the first
render) latched resolveFailed=true, and nothing ever cleared it — so
the FAB stayed hidden permanently even after a later retry succeeded.
Reset the flag at the start of each resolution attempt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:17:20 +10:00

429 lines
16 KiB
TypeScript

// 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
// 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 && (
<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>
)
}