feat: sendToDock — external-send bus into the global dock

Any feature surface can open the dock and send a message as the user
("present this record", "ask about this file"). Goes through the
normal send path; supports display≠wire for long payloads; buffers one
message across mount races and queues until the agent resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jules
2026-06-11 18:41:54 +10:00
parent e4294fe896
commit 8974bcdd5d

View File

@@ -7,22 +7,24 @@
// =========================================================================== // ===========================================================================
// EXPORTS // EXPORTS
// Component: AgentDock // Component: AgentDock
// Types: AgentDockProps, AgentDockTransport, AgentRef, PageContext // Functions: sendToDock
// Types: AgentDockProps, AgentDockTransport, AgentRef, PageContext,
// DockMessage
// =========================================================================== // ===========================================================================
"use client" "use client";
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, Maximize2, Plus, Send, Sparkles, X } from "lucide-react" import { Loader2, Maximize2, Plus, Send, Sparkles, X } from "lucide-react";
import { MessageBody } from "@crema/agent-ui" import { MessageBody } from "@crema/agent-ui";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Public types */ /* Public types */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export type AgentRef = { export type AgentRef = {
id: string id: string;
name: string name: string;
/** Optional "try saying…" prompts shown to the user above the empty /** Optional "try saying…" prompts shown to the user above the empty
* composer on first chat. Click sends the prompt verbatim. The dock * composer on first chat. Click sends the prompt verbatim. The dock
* doesn't curate or persist these — the host app decides what to * doesn't curate or persist these — the host app decides what to
@@ -30,8 +32,8 @@ export type AgentRef = {
* *
* Limit ~4 to keep the panel light; the dock won't truncate but * Limit ~4 to keep the panel light; the dock won't truncate but
* more rows make the chat feel cluttered. */ * more rows make the chat feel cluttered. */
starter_suggestions?: string[] starter_suggestions?: string[];
} };
/** Structured page-awareness signal. Travels to the platform as its own /** Structured page-awareness signal. Travels to the platform as its own
* field — never concatenated into the user message — so page content * field — never concatenated into the user message — so page content
@@ -39,12 +41,12 @@ export type AgentRef = {
* scraped DOM text. */ * scraped DOM text. */
export type PageContext = { export type PageContext = {
/** App route the user is currently viewing, e.g. `/invoices`. */ /** App route the user is currently viewing, e.g. `/invoices`. */
route: string route: string;
/** Optional stable resource identifier — type + id, app-authored. */ /** Optional stable resource identifier — type + id, app-authored. */
resource?: { type: string; id: string } resource?: { type: string; id: string };
/** Optional short, app-authored label (a stable descriptor). */ /** Optional short, app-authored label (a stable descriptor). */
label?: string label?: string;
} };
/** The single platform call the dock needs. The app implements this — /** The single platform call the dock needs. The app implements this —
* typically by delegating to its `@crema/arcadia-agents-client` instance * typically by delegating to its `@crema/arcadia-agents-client` instance
@@ -54,37 +56,63 @@ export interface AgentDockTransport {
chat( chat(
agentId: string, agentId: string,
req: { req: {
user_message: string user_message: string;
conversation_id?: string conversation_id?: string;
page_context?: PageContext page_context?: PageContext;
}, },
): Promise<{ ): Promise<{
message: string message: string;
conversation_id: string conversation_id: string;
citations?: { chunk_id: string }[] citations?: { chunk_id: string }[];
}> }>;
}
/** A message pushed into the dock from elsewhere in the app. `display` is
* what the user bubble shows; `wire` is what the agent receives — they
* differ when the payload is long (e.g. a presented record: a short label
* on screen, the full Markdown on the wire). */
export type DockMessage = { display: string; wire: string };
/* External-send bus: lets any feature surface push a message into the
* global dock ("present this record", "ask about this file"). The dock
* opens and the message goes through its normal send path — page context,
* threading and rich-block rendering all apply. One message is buffered
* if no dock is mounted yet (mount races during navigation). */
const dockListeners = new Set<(m: DockMessage) => void>();
let pendingDockMessage: DockMessage | null = null;
/** Open the global dock and send `message` as the user. Accepts a plain
* string (shown verbatim) or a `{display, wire}` pair. */
export function sendToDock(message: string | DockMessage): void {
const m =
typeof message === "string" ? { display: message, wire: message } : message;
if (dockListeners.size === 0) {
pendingDockMessage = m;
return;
}
for (const l of dockListeners) l(m);
} }
export interface AgentDockProps { export interface AgentDockProps {
/** Platform transport — auth is baked in here by the app. */ /** Platform transport — auth is baked in here by the app. */
transport: AgentDockTransport transport: AgentDockTransport;
/** Resolve the agent(s) the user can talk to. The dock renders a picker /** 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 * iff more than one is returned; the first (or `defaultAgentId`) is the
* default. A rejected promise disables the dock (renders nothing) — * default. A rejected promise disables the dock (renders nothing) —
* e.g. a tenant with no personal-agent template. */ * e.g. a tenant with no personal-agent template. */
resolveAgents: () => Promise<AgentRef[]> resolveAgents: () => Promise<AgentRef[]>;
/** Preferred default agent id when `resolveAgents` returns several. */ /** Preferred default agent id when `resolveAgents` returns several. */
defaultAgentId?: string defaultAgentId?: string;
/** Called on every send. Return the structured page context to attach, /** Called on every send. Return the structured page context to attach,
* or null to send none. The app decides what counts as "context". */ * or null to send none. The app decides what counts as "context". */
getPageContext?: () => PageContext | null getPageContext?: () => PageContext | null;
/** ⤢ handler. Omit to hide the expand button. The app owns what expand /** ⤢ handler. Omit to hide the expand button. The app owns what expand
* means — navigate to a full chat route, mount its own modal, etc. */ * means — navigate to a full chat route, mount its own modal, etc. */
onExpand?: (a: { agentId: string; conversationId: string | null }) => void onExpand?: (a: { agentId: string; conversationId: string | null }) => void;
/** App-computed per-route suppression — the app knows its own routes. */ /** App-computed per-route suppression — the app knows its own routes. */
hidden?: boolean hidden?: boolean;
/** localStorage namespace for the open/closed state. */ /** localStorage namespace for the open/closed state. */
storageKey?: string storageKey?: string;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -93,16 +121,24 @@ export interface AgentDockProps {
type Bubble = type Bubble =
| { role: "user"; text: string } | { role: "user"; text: string }
| { role: "assistant"; text: string; finished: boolean; error: string | null } | {
role: "assistant";
text: string;
finished: boolean;
error: string | null;
};
const DEFAULT_STORAGE_KEY = "crema.agent-dock.open" const DEFAULT_STORAGE_KEY = "crema.agent-dock.open";
function pickDefault(agents: AgentRef[], preferredId?: string): AgentRef | null { function pickDefault(
agents: AgentRef[],
preferredId?: string,
): AgentRef | null {
if (preferredId) { if (preferredId) {
const match = agents.find((a) => a.id === preferredId) const match = agents.find((a) => a.id === preferredId);
if (match) return match if (match) return match;
} }
return agents[0] ?? null return agents[0] ?? null;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -119,122 +155,154 @@ export function AgentDock({
storageKey = DEFAULT_STORAGE_KEY, storageKey = DEFAULT_STORAGE_KEY,
}: AgentDockProps) { }: AgentDockProps) {
const [open, setOpen] = useState<boolean>(() => { const [open, setOpen] = useState<boolean>(() => {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false;
return localStorage.getItem(storageKey) === "1" return localStorage.getItem(storageKey) === "1";
}) });
useEffect(() => { useEffect(() => {
localStorage.setItem(storageKey, open ? "1" : "0") localStorage.setItem(storageKey, open ? "1" : "0");
}, [open, storageKey]) }, [open, storageKey]);
const [agents, setAgents] = useState<AgentRef[] | null>(null) const [agents, setAgents] = useState<AgentRef[] | null>(null);
const [agentId, setAgentId] = useState<string | null>(null) const [agentId, setAgentId] = useState<string | null>(null);
const [resolveFailed, setResolveFailed] = useState(false) const [resolveFailed, setResolveFailed] = useState(false);
const [bubbles, setBubbles] = useState<Bubble[]>([]) const [bubbles, setBubbles] = useState<Bubble[]>([]);
const [conversationId, setConversationId] = useState<string | null>(null) const [conversationId, setConversationId] = useState<string | null>(null);
const [draft, setDraft] = useState("") const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null) const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false;
// Clear any prior failure so a retry (e.g. tenant id arrives after // Clear any prior failure so a retry (e.g. tenant id arrives after
// the first render) gets a clean slate — otherwise the flag latches // the first render) gets a clean slate — otherwise the flag latches
// and the FAB stays hidden forever even once resolution succeeds. // and the FAB stays hidden forever even once resolution succeeds.
setResolveFailed(false) setResolveFailed(false);
resolveAgents() resolveAgents()
.then((list) => { .then((list) => {
if (cancelled) return if (cancelled) return;
setAgents(list) setAgents(list);
setAgentId((cur) => cur ?? pickDefault(list, defaultAgentId)?.id ?? null) setAgentId(
(cur) => cur ?? pickDefault(list, defaultAgentId)?.id ?? null,
);
}) })
.catch(() => { .catch(() => {
if (!cancelled) setResolveFailed(true) if (!cancelled) setResolveFailed(true);
}) });
return () => { return () => {
cancelled = true cancelled = true;
} };
}, [resolveAgents, defaultAgentId]) }, [resolveAgents, defaultAgentId]);
// Switching agent starts a fresh thread — don't cross-contaminate the // Switching agent starts a fresh thread — don't cross-contaminate the
// assistant's context across agents. // assistant's context across agents.
useEffect(() => { useEffect(() => {
setBubbles([]) setBubbles([]);
setConversationId(null) setConversationId(null);
}, [agentId]) }, [agentId]);
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight, top: scrollRef.current.scrollHeight,
behavior: "auto", behavior: "auto",
}) });
}, [bubbles]) }, [bubbles]);
// `display` is what shows in the user bubble; `wire` is what the agent // `display` is what shows in the user bubble; `wire` is what the agent
// receives. They differ only for rich-block action clicks, where the // receives. They differ only for rich-block action clicks, where the
// button label is shown but the action's `value` is sent. // button label is shown but the action's `value` is sent.
const sendMessage = useCallback( const sendMessage = useCallback(
async (display: string, wire: string) => { async (display: string, wire: string) => {
if (!agentId || sending) return if (!agentId || sending) return;
const trimmed = wire.trim() const trimmed = wire.trim();
if (!trimmed) return if (!trimmed) return;
setBubbles((bs) => [ setBubbles((bs) => [
...bs, ...bs,
{ role: "user", text: display }, { role: "user", text: display },
{ role: "assistant", text: "", finished: false, error: null }, { role: "assistant", text: "", finished: false, error: null },
]) ]);
setSending(true) setSending(true);
try { try {
// Non-streaming on purpose: the streaming path injects // Non-streaming on purpose: the streaming path injects
// stream_options.include_usage, which DeepSeek/Qwen/LM Studio // stream_options.include_usage, which DeepSeek/Qwen/LM Studio
// reject. Dock answers are short; ⤢ opens the full chat surface. // reject. Dock answers are short; ⤢ opens the full chat surface.
const pc = getPageContext?.() ?? undefined const pc = getPageContext?.() ?? undefined;
const r = await transport.chat(agentId, { const r = await transport.chat(agentId, {
user_message: trimmed, user_message: trimmed,
conversation_id: conversationId ?? undefined, conversation_id: conversationId ?? undefined,
page_context: pc ?? undefined, page_context: pc ?? undefined,
}) });
if (r.conversation_id && !conversationId) if (r.conversation_id && !conversationId)
setConversationId(r.conversation_id) setConversationId(r.conversation_id);
setBubbles((bs) => { setBubbles((bs) => {
const last = bs[bs.length - 1] const last = bs[bs.length - 1];
if (!last || last.role !== "assistant") return bs if (!last || last.role !== "assistant") return bs;
const next = [...bs] const next = [...bs];
next[next.length - 1] = { ...last, text: r.message, finished: true } next[next.length - 1] = { ...last, text: r.message, finished: true };
return next return next;
}) });
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e);
setBubbles((bs) => { setBubbles((bs) => {
const last = bs[bs.length - 1] const last = bs[bs.length - 1];
if (!last || last.role !== "assistant") return bs if (!last || last.role !== "assistant") return bs;
const next = [...bs] const next = [...bs];
next[next.length - 1] = { ...last, error: msg, finished: true } next[next.length - 1] = { ...last, error: msg, finished: true };
return next return next;
}) });
} finally { } finally {
setSending(false) setSending(false);
} }
}, },
[agentId, sending, conversationId, transport, getPageContext], [agentId, sending, conversationId, transport, getPageContext],
) );
const submitDraft = useCallback(() => { const submitDraft = useCallback(() => {
const text = draft.trim() const text = draft.trim();
if (!text) return if (!text) return;
setDraft("") setDraft("");
void sendMessage(text, text) void sendMessage(text, text);
}, [draft, sendMessage]) }, [draft, sendMessage]);
// External sends (`sendToDock`): open the panel and run the message
// through the normal send path. Messages arriving before the agent has
// resolved (or while a send is in flight) queue locally and flush as
// soon as sendMessage can act.
const externalQueue = useRef<DockMessage[]>([]);
const [externalTick, setExternalTick] = useState(0);
useEffect(() => {
const onExternal = (m: DockMessage) => {
setOpen(true);
externalQueue.current.push(m);
setExternalTick((t) => t + 1);
};
dockListeners.add(onExternal);
if (pendingDockMessage) {
const m = pendingDockMessage;
pendingDockMessage = null;
onExternal(m);
}
return () => {
dockListeners.delete(onExternal);
};
}, []);
useEffect(() => {
if (!agentId || sending) return;
const next = externalQueue.current.shift();
if (next) void sendMessage(next.display, next.wire);
}, [externalTick, agentId, sending, sendMessage]);
function newThread() { function newThread() {
if (sending) return if (sending) return;
setBubbles([]) setBubbles([]);
setConversationId(null) setConversationId(null);
} }
if (hidden || resolveFailed) return null if (hidden || resolveFailed) return null;
const currentAgent = agents?.find((a) => a.id === agentId) ?? null const currentAgent = agents?.find((a) => a.id === agentId) ?? null;
const title = currentAgent?.name ?? "Assistant" const title = currentAgent?.name ?? "Assistant";
return ( return (
<> <>
@@ -304,9 +372,9 @@ export function AgentDock({
dataAction="assistant-dock-expand" dataAction="assistant-dock-expand"
disabled={!agentId} disabled={!agentId}
onClick={() => { onClick={() => {
if (!agentId) return if (!agentId) return;
onExpand({ agentId, conversationId }) onExpand({ agentId, conversationId });
setOpen(false) setOpen(false);
}} }}
> >
<Maximize2 className="size-4" /> <Maximize2 className="size-4" />
@@ -323,7 +391,10 @@ export function AgentDock({
</div> </div>
{/* Messages */} {/* Messages */}
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4"> <div
ref={scrollRef}
className="flex-1 space-y-4 overflow-y-auto p-4"
>
{bubbles.length === 0 ? ( {bubbles.length === 0 ? (
<div className="space-y-4 pt-6"> <div className="space-y-4 pt-6">
<p className="text-center text-sm text-[var(--foreground)]/80"> <p className="text-center text-sm text-[var(--foreground)]/80">
@@ -408,8 +479,8 @@ export function AgentDock({
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault() e.preventDefault();
submitDraft() submitDraft();
} }
}} }}
placeholder={agentId ? "Message…" : "No agent selected"} placeholder={agentId ? "Message…" : "No agent selected"}
@@ -438,7 +509,7 @@ export function AgentDock({
</div> </div>
)} )}
</> </>
) );
} }
function IconButton({ function IconButton({
@@ -448,11 +519,11 @@ function IconButton({
onClick, onClick,
children, children,
}: { }: {
label: string label: string;
dataAction: string dataAction: string;
disabled?: boolean disabled?: boolean;
onClick: () => void onClick: () => void;
children: React.ReactNode children: React.ReactNode;
}) { }) {
return ( return (
<button <button
@@ -466,5 +537,5 @@ function IconButton({
> >
{children} {children}
</button> </button>
) );
} }