From 8974bcdd5d88ea570738ff9cc90af187b74a3db9 Mon Sep 17 00:00:00 2001 From: jules Date: Thu, 11 Jun 2026 18:41:54 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20sendToDock=20=E2=80=94=20external-send?= =?UTF-8?q?=20bus=20into=20the=20global=20dock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/index.tsx | 287 +++++++++++++++++++++++++++++++------------------- 1 file changed, 179 insertions(+), 108 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 14a72db..926d3bc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,22 +7,24 @@ // =========================================================================== // EXPORTS // 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 { Loader2, Maximize2, Plus, Send, Sparkles, X } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "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 */ /* ------------------------------------------------------------------ */ export type AgentRef = { - id: string - name: string + id: string; + name: string; /** Optional "try saying…" prompts shown to the user above the empty * composer on first chat. Click sends the prompt verbatim. The dock * 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 * more rows make the chat feel cluttered. */ - starter_suggestions?: string[] -} + starter_suggestions?: string[]; +}; /** Structured page-awareness signal. Travels to the platform as its own * field — never concatenated into the user message — so page content @@ -39,12 +41,12 @@ export type AgentRef = { * scraped DOM text. */ export type PageContext = { /** App route the user is currently viewing, e.g. `/invoices`. */ - route: string + route: string; /** 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). */ - label?: string -} + label?: string; +}; /** The single platform call the dock needs. The app implements this — * typically by delegating to its `@crema/arcadia-agents-client` instance @@ -54,37 +56,63 @@ export interface AgentDockTransport { chat( agentId: string, req: { - user_message: string - conversation_id?: string - page_context?: PageContext + user_message: string; + conversation_id?: string; + page_context?: PageContext; }, ): Promise<{ - message: string - conversation_id: string - citations?: { chunk_id: string }[] - }> + message: string; + conversation_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 { /** 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 * 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 + resolveAgents: () => Promise; /** Preferred default agent id when `resolveAgents` returns several. */ - defaultAgentId?: string + 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 + 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 + onExpand?: (a: { agentId: string; conversationId: string | null }) => void; /** App-computed per-route suppression — the app knows its own routes. */ - hidden?: boolean + hidden?: boolean; /** localStorage namespace for the open/closed state. */ - storageKey?: string + storageKey?: string; } /* ------------------------------------------------------------------ */ @@ -93,16 +121,24 @@ export interface AgentDockProps { type Bubble = | { 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) { - const match = agents.find((a) => a.id === preferredId) - if (match) return match + const match = agents.find((a) => a.id === preferredId); + if (match) return match; } - return agents[0] ?? null + return agents[0] ?? null; } /* ------------------------------------------------------------------ */ @@ -119,122 +155,154 @@ export function AgentDock({ storageKey = DEFAULT_STORAGE_KEY, }: AgentDockProps) { const [open, setOpen] = useState(() => { - if (typeof window === "undefined") return false - return localStorage.getItem(storageKey) === "1" - }) + if (typeof window === "undefined") return false; + return localStorage.getItem(storageKey) === "1"; + }); useEffect(() => { - localStorage.setItem(storageKey, open ? "1" : "0") - }, [open, storageKey]) + 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) + 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 + 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) + setResolveFailed(false); resolveAgents() .then((list) => { - if (cancelled) return - setAgents(list) - setAgentId((cur) => cur ?? pickDefault(list, defaultAgentId)?.id ?? null) + if (cancelled) return; + setAgents(list); + setAgentId( + (cur) => cur ?? pickDefault(list, defaultAgentId)?.id ?? null, + ); }) .catch(() => { - if (!cancelled) setResolveFailed(true) - }) + if (!cancelled) setResolveFailed(true); + }); return () => { - cancelled = true - } - }, [resolveAgents, defaultAgentId]) + 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]) + setBubbles([]); + setConversationId(null); + }, [agentId]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "auto", - }) - }, [bubbles]) + }); + }, [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 + 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) + ]); + 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 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) + 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 - }) + 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) + 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 - }) + 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) + setSending(false); } }, [agentId, sending, conversationId, transport, getPageContext], - ) + ); const submitDraft = useCallback(() => { - const text = draft.trim() - if (!text) return - setDraft("") - void sendMessage(text, text) - }, [draft, sendMessage]) + const text = draft.trim(); + if (!text) return; + setDraft(""); + void sendMessage(text, text); + }, [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([]); + 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() { - if (sending) return - setBubbles([]) - setConversationId(null) + if (sending) return; + setBubbles([]); + setConversationId(null); } - if (hidden || resolveFailed) return null + if (hidden || resolveFailed) return null; - const currentAgent = agents?.find((a) => a.id === agentId) ?? null - const title = currentAgent?.name ?? "Assistant" + const currentAgent = agents?.find((a) => a.id === agentId) ?? null; + const title = currentAgent?.name ?? "Assistant"; return ( <> @@ -304,9 +372,9 @@ export function AgentDock({ dataAction="assistant-dock-expand" disabled={!agentId} onClick={() => { - if (!agentId) return - onExpand({ agentId, conversationId }) - setOpen(false) + if (!agentId) return; + onExpand({ agentId, conversationId }); + setOpen(false); }} > @@ -323,7 +391,10 @@ export function AgentDock({ {/* Messages */} -
+
{bubbles.length === 0 ? (

@@ -408,8 +479,8 @@ export function AgentDock({ onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - submitDraft() + e.preventDefault(); + submitDraft(); } }} placeholder={agentId ? "Message…" : "No agent selected"} @@ -438,7 +509,7 @@ export function AgentDock({

)} - ) + ); } function IconButton({ @@ -448,11 +519,11 @@ function IconButton({ onClick, children, }: { - label: string - dataAction: string - disabled?: boolean - onClick: () => void - children: React.ReactNode + label: string; + dataAction: string; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; }) { return ( - ) + ); }