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>
429 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|