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
// 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<AgentRef[]>
resolveAgents: () => Promise<AgentRef[]>;
/** 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<boolean>(() => {
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<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)
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
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<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() {
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);
}}
>
<Maximize2 className="size-4" />
@@ -323,7 +391,10 @@ export function AgentDock({
</div>
{/* 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 ? (
<div className="space-y-4 pt-6">
<p className="text-center text-sm text-[var(--foreground)]/80">
@@ -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({
</div>
)}
</>
)
);
}
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 (
<button
@@ -466,5 +537,5 @@ function IconButton({
>
{children}
</button>
)
);
}