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:
287
src/index.tsx
287
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<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user