@camelai/agent-runtime-react 0.0.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { useAgentStatus, useSend } from "../index.js";
4
+ import { cx, useUI } from "./context.js";
5
+ /**
6
+ * The message box: Enter sends, Shift+Enter makes a new line, Escape stops the agent. While the agent
7
+ * works, an empty box offers Stop; typing turns it back into Send.
8
+ */
9
+ export const Composer = forwardRef(function Composer({ placeholder, autoFocus, whileRunning, data, className, onSend }, ref) {
10
+ const { labels } = useUI();
11
+ const send = useSend();
12
+ const { isRunning, stop } = useAgentStatus();
13
+ const [text, setText] = useState("");
14
+ const area = useRef(null);
15
+ useImperativeHandle(ref, () => ({ focus: () => area.current?.focus(), setText }), []);
16
+ // Grow with the text, up to the height the stylesheet allows.
17
+ useLayoutEffect(() => {
18
+ const element = area.current;
19
+ if (!element || typeof CSS !== "undefined" && CSS.supports?.("field-sizing", "content"))
20
+ return;
21
+ element.style.height = "auto";
22
+ element.style.height = `${element.scrollHeight}px`;
23
+ }, [text]);
24
+ const submit = (event) => {
25
+ event?.preventDefault();
26
+ const message = text.trim();
27
+ if (!message) {
28
+ if (isRunning)
29
+ void stop();
30
+ return;
31
+ }
32
+ setText("");
33
+ const extra = typeof data === "function" ? data() : data;
34
+ void send(message, { ...(extra !== undefined ? { data: extra } : {}), ...(whileRunning ? { whileRunning } : {}) });
35
+ onSend?.(message);
36
+ };
37
+ const onKeyDown = (event) => {
38
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
39
+ event.preventDefault();
40
+ submit();
41
+ }
42
+ else if (event.key === "Escape" && isRunning) {
43
+ event.preventDefault();
44
+ void stop();
45
+ }
46
+ };
47
+ const stopping = isRunning && !text.trim();
48
+ return (_jsxs("form", { className: cx("agent-chat__composer", className), onSubmit: submit, children: [_jsx("textarea", { ref: area, rows: 1, value: text, autoFocus: autoFocus, placeholder: placeholder ?? labels.placeholder, "aria-label": labels.composer, onChange: event => setText(event.currentTarget.value), onKeyDown: onKeyDown }), _jsx("button", { type: "submit", className: cx("agent-chat__send", stopping && "agent-chat__send--stop"), disabled: !stopping && !text.trim(), "aria-label": stopping ? labels.stop : labels.send, children: _jsx("span", { "aria-hidden": "true", className: "agent-chat__send-icon" }) })] }));
49
+ });
@@ -0,0 +1,87 @@
1
+ import { type ComponentType, type ReactNode } from "react";
2
+ import type { AssistantChatMessage, ChatInput, FilePart, ToolPart, UserChatMessage } from "@camelai/agent-runtime/chat";
3
+ import type { ToolRenderers } from "../index.tsx";
4
+ /** Every string the components show, for translation or tone. */
5
+ export interface Labels {
6
+ placeholder: string;
7
+ send: string;
8
+ stop: string;
9
+ composer: string;
10
+ conversation: string;
11
+ thinking: string;
12
+ reasoning: string;
13
+ responding: string;
14
+ replied: string;
15
+ needsInput: string;
16
+ jumpToLatest: string;
17
+ loadOlder: string;
18
+ loadingOlder: string;
19
+ emptyTitle: string;
20
+ approve: string;
21
+ deny: string;
22
+ submit: string;
23
+ skip: string;
24
+ other: string;
25
+ open: string;
26
+ done: string;
27
+ cancel: string;
28
+ retry: string;
29
+ reconnecting: string;
30
+ sending: string;
31
+ failed: string;
32
+ stopped: string;
33
+ copy: string;
34
+ copied: string;
35
+ download: string;
36
+ toolRunning: (name: string) => string;
37
+ toolDone: (name: string) => string;
38
+ toolFailed: (name: string) => string;
39
+ toolWaiting: (name: string) => string;
40
+ arguments: string;
41
+ result: string;
42
+ }
43
+ export declare const defaultLabels: Labels;
44
+ /** Parts of the chat you can replace with your own components. */
45
+ export interface AgentChatComponents {
46
+ /** Renders a message's markdown. Swap in react-markdown or Streamdown if you like. */
47
+ Markdown: ComponentType<{
48
+ text: string;
49
+ streaming: boolean;
50
+ className?: string;
51
+ }>;
52
+ UserMessage: ComponentType<{
53
+ message: UserChatMessage;
54
+ }>;
55
+ AssistantMessage: ComponentType<{
56
+ message: AssistantChatMessage;
57
+ }>;
58
+ /** A tool call with no renderer of its own. */
59
+ ToolFallback: ComponentType<{
60
+ part: ToolPart;
61
+ }>;
62
+ /** What the agent waits on: an approval, questions, a form or a link to open. */
63
+ InputCard: ComponentType<{
64
+ input: ChatInput;
65
+ }>;
66
+ FilePreview: ComponentType<{
67
+ part: FilePart;
68
+ }>;
69
+ /** Shown before the first message. */
70
+ EmptyState: ComponentType<{
71
+ suggestions: string[];
72
+ send(text: string): void;
73
+ }>;
74
+ }
75
+ export interface UIContextValue {
76
+ labels: Labels;
77
+ components: AgentChatComponents;
78
+ tools?: ToolRenderers;
79
+ allowImages: boolean;
80
+ showReasoning: boolean;
81
+ }
82
+ export declare const UIContext: import("react").Context<UIContextValue | null>;
83
+ export declare function useUI(): UIContextValue;
84
+ export declare const cx: (...names: (string | false | null | undefined)[]) => string;
85
+ export type Children = {
86
+ children?: ReactNode;
87
+ };
@@ -0,0 +1,46 @@
1
+ import { createContext, useContext } from "react";
2
+ export const defaultLabels = {
3
+ placeholder: "Message the agent…",
4
+ send: "Send",
5
+ stop: "Stop",
6
+ composer: "Message",
7
+ conversation: "Conversation",
8
+ thinking: "Thinking…",
9
+ reasoning: "Reasoning",
10
+ responding: "The assistant is responding.",
11
+ replied: "The assistant replied:",
12
+ needsInput: "The assistant needs your input.",
13
+ jumpToLatest: "Jump to latest",
14
+ loadOlder: "Load earlier messages",
15
+ loadingOlder: "Loading…",
16
+ emptyTitle: "How can I help?",
17
+ approve: "Approve",
18
+ deny: "Deny",
19
+ submit: "Submit",
20
+ skip: "Skip",
21
+ other: "Other",
22
+ open: "Open",
23
+ done: "Done",
24
+ cancel: "Cancel",
25
+ retry: "Retry",
26
+ reconnecting: "Reconnecting…",
27
+ sending: "Sending…",
28
+ failed: "Not sent.",
29
+ stopped: "Stopped.",
30
+ copy: "Copy",
31
+ copied: "Copied",
32
+ download: "Download",
33
+ toolRunning: name => `Running ${name}…`,
34
+ toolDone: name => `Used ${name}`,
35
+ toolFailed: name => `${name} failed`,
36
+ toolWaiting: name => `${name} is waiting for you`,
37
+ arguments: "Arguments",
38
+ result: "Result",
39
+ };
40
+ export const UIContext = createContext(null);
41
+ /** Outside <AgentChat> (a Markdown of your own, say): the default labels, and no images. */
42
+ const OUTSIDE = { labels: defaultLabels, allowImages: false, showReasoning: true };
43
+ export function useUI() {
44
+ return useContext(UIContext) ?? OUTSIDE;
45
+ }
46
+ export const cx = (...names) => names.filter(Boolean).join(" ");
@@ -0,0 +1,9 @@
1
+ import type { FilePart } from "@camelai/agent-runtime/chat";
2
+ /**
3
+ * A file the agent handed over: an image inline, anything else as a download. Its signed link comes
4
+ * with the stream when it is presented; for older files it is fetched through your handler, and
5
+ * fetched again before a click once it is about to expire.
6
+ */
7
+ export declare const FilePreview: import("react").MemoExoticComponent<({ part }: {
8
+ part: FilePart;
9
+ }) => import("react").JSX.Element>;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useCallback, useEffect, useState } from "react";
3
+ import { useAgent } from "../index.js";
4
+ import { useUI } from "./context.js";
5
+ const size = (bytes) => bytes === undefined ? "" : bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 ** 2).toFixed(1)} MB`;
6
+ /**
7
+ * A file the agent handed over: an image inline, anything else as a download. Its signed link comes
8
+ * with the stream when it is presented; for older files it is fetched through your handler, and
9
+ * fetched again before a click once it is about to expire.
10
+ */
11
+ export const FilePreview = memo(function FilePreview({ part }) {
12
+ const chat = useAgent();
13
+ const { labels } = useUI();
14
+ const [url, setUrl] = useState(part.url);
15
+ const [failed, setFailed] = useState(false);
16
+ const refresh = useCallback(() => {
17
+ chat.fileUrl(part.path).then(link => { setUrl(link); setFailed(false); }, () => setFailed(true));
18
+ }, [chat, part.path]);
19
+ useEffect(() => { if (part.url)
20
+ setUrl(part.url);
21
+ else
22
+ refresh(); }, [part.url, refresh]);
23
+ const image = part.contentType?.startsWith("image/") && part.contentType !== "image/svg+xml";
24
+ return (_jsxs("figure", { className: "agent-chat__file", "data-kind": image ? "image" : "file", children: [image && url && !failed && _jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", children: _jsx("img", { src: url, alt: part.caption ?? part.name, loading: "lazy", onError: () => setFailed(true) }) }), _jsxs("figcaption", { children: [_jsxs("a", { className: "agent-chat__file-link", href: url, target: "_blank", rel: "noopener noreferrer", download: part.name, onMouseEnter: refresh, onFocus: refresh, "aria-disabled": !url || undefined, children: [_jsx("span", { className: "agent-chat__file-name", children: part.name }), _jsx("span", { className: "agent-chat__file-meta", children: [size(part.size), labels.download].filter(Boolean).join(" · ") })] }), part.caption && _jsx("span", { className: "agent-chat__file-caption", children: part.caption })] })] }));
25
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A prebuilt chat with the user's agent: messages that stream in as markdown, tool cards (or your own
3
+ * components per tool), the agent's questions and approvals, files it hands over, a composer with
4
+ * stop, and scrolling that follows the reply. Themed with CSS variables; import the stylesheet once:
5
+ *
6
+ * import { AgentChat } from "@camelai/agent-runtime-react/ui";
7
+ * import "@camelai/agent-runtime-react/styles.css";
8
+ *
9
+ * <AgentChat endpoint="/api/agent" suggestions={["Where is my order?"]} />
10
+ */
11
+ import { type CSSProperties, type ReactNode } from "react";
12
+ import type { AgentChat as Chat } from "@camelai/agent-runtime/chat";
13
+ import { type ToolRenderers, type UseAgentChatOptions } from "../index.tsx";
14
+ import { Composer, type ComposerHandle, type ComposerProps } from "./composer.tsx";
15
+ import { defaultLabels, type AgentChatComponents, type Labels } from "./context.tsx";
16
+ import { FilePreview } from "./file.tsx";
17
+ import { Markdown } from "./markdown.tsx";
18
+ import { AssistantMessage, EmptyState, MessageList, UserMessage } from "./messages.tsx";
19
+ import { InputCard, ToolCard, ToolView } from "./tool.tsx";
20
+ export { Composer, FilePreview, InputCard, Markdown, MessageList, ToolCard, ToolView, UserMessage, AssistantMessage, EmptyState, defaultLabels };
21
+ export type { AgentChatComponents, ComposerHandle, ComposerProps, Labels };
22
+ export interface AgentChatProps extends Partial<UseAgentChatOptions> {
23
+ /** A chat you made (`useAgentChat`); else `endpoint`; else the nearest <AgentProvider>'s. */
24
+ chat?: Chat;
25
+ /** Your own components for tool calls, by tool name (generative UI). */
26
+ tools?: ToolRenderers;
27
+ /** Replace parts of the chat: Markdown, UserMessage, AssistantMessage, ToolFallback, InputCard, FilePreview, EmptyState. */
28
+ components?: Partial<AgentChatComponents>;
29
+ labels?: Partial<Labels>;
30
+ placeholder?: string;
31
+ /** Prompts offered before the first message. */
32
+ suggestions?: string[];
33
+ /** "light", "dark", or "system" (default: the page's `prefers-color-scheme`, or a `.dark` ancestor). */
34
+ theme?: "light" | "dark" | "system";
35
+ /** Show images the agent links in its markdown (off by default: loading them tells their server about the reader). */
36
+ allowImages?: boolean;
37
+ /** Show the model's reasoning, collapsed (default true). */
38
+ showReasoning?: boolean;
39
+ autoFocus?: boolean;
40
+ /** JSON sent with each message, for your handler's `onSend`. */
41
+ data?: ComposerProps["data"];
42
+ /** Content above the messages (a title bar). */
43
+ header?: ReactNode;
44
+ className?: string;
45
+ style?: CSSProperties;
46
+ }
47
+ /** The whole chat. Inside an <AgentProvider> it uses that chat; with `endpoint` it makes its own. */
48
+ export declare function AgentChat({ chat, endpoint, tools, ...props }: AgentChatProps): import("react").JSX.Element;
49
+ /** The chat's layout, for the chat of the nearest <AgentProvider>. */
50
+ export declare function AgentChatRoot({ tools, components, labels, placeholder, suggestions, theme, allowImages, showReasoning, autoFocus, data, header, className, style, whileRunning, }: Omit<AgentChatProps, "chat" | "endpoint">): import("react").JSX.Element;
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * A prebuilt chat with the user's agent: messages that stream in as markdown, tool cards (or your own
5
+ * components per tool), the agent's questions and approvals, files it hands over, a composer with
6
+ * stop, and scrolling that follows the reply. Themed with CSS variables; import the stylesheet once:
7
+ *
8
+ * import { AgentChat } from "@camelai/agent-runtime-react/ui";
9
+ * import "@camelai/agent-runtime-react/styles.css";
10
+ *
11
+ * <AgentChat endpoint="/api/agent" suggestions={["Where is my order?"]} />
12
+ */
13
+ import { useMemo } from "react";
14
+ import { AgentProvider, useAgent, useAgentSelector, useAgentStatus } from "../index.js";
15
+ import { Composer } from "./composer.js";
16
+ import { cx, defaultLabels, UIContext, useUI } from "./context.js";
17
+ import { FilePreview } from "./file.js";
18
+ import { Markdown } from "./markdown.js";
19
+ import { AssistantMessage, EmptyState, MessageList, UserMessage } from "./messages.js";
20
+ import { InputCard, ToolCard, ToolView } from "./tool.js";
21
+ export { Composer, FilePreview, InputCard, Markdown, MessageList, ToolCard, ToolView, UserMessage, AssistantMessage, EmptyState, defaultLabels };
22
+ /** The whole chat. Inside an <AgentProvider> it uses that chat; with `endpoint` it makes its own. */
23
+ export function AgentChat({ chat, endpoint, tools, ...props }) {
24
+ const { thread, headers, credentials, whileRunning, watch, onEvent, onError, fetch, ...rest } = props;
25
+ if (chat || endpoint) {
26
+ const options = { thread, headers, credentials, whileRunning, watch, onEvent, onError, fetch };
27
+ const defined = Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
28
+ return _jsx(AgentProvider, { chat: chat, endpoint: endpoint, ...defined, children: _jsx(AgentChatRoot, { tools: tools, whileRunning: whileRunning, ...rest }) });
29
+ }
30
+ return _jsx(AgentChatRoot, { tools: tools, whileRunning: whileRunning, ...rest });
31
+ }
32
+ const DEFAULT_COMPONENTS = { Markdown, UserMessage, AssistantMessage, ToolFallback: ToolCard, InputCard, FilePreview, EmptyState };
33
+ /** The chat's layout, for the chat of the nearest <AgentProvider>. */
34
+ export function AgentChatRoot({ tools, components, labels, placeholder, suggestions, theme = "system", allowImages = false, showReasoning = true, autoFocus, data, header, className, style, whileRunning, }) {
35
+ useAgent();
36
+ const value = useMemo(() => ({
37
+ labels: { ...defaultLabels, ...labels }, components: { ...DEFAULT_COMPONENTS, ...components }, tools, allowImages, showReasoning,
38
+ }), [labels, components, tools, allowImages, showReasoning]);
39
+ return (_jsx(UIContext.Provider, { value: value, children: _jsxs("div", { className: cx("agent-chat", className), style: style, "data-theme": theme === "system" ? undefined : theme, children: [header, _jsx(MessageList, { suggestions: suggestions }), _jsx(LooseInputs, {}), _jsx(ErrorBanner, {}), _jsx(Composer, { placeholder: placeholder, autoFocus: autoFocus, data: data, whileRunning: whileRunning })] }) }));
40
+ }
41
+ /** Inputs whose call is not on screen (its turn is on an older page, or a tool asked outside a call). */
42
+ function LooseInputs() {
43
+ const { components } = useUI();
44
+ const loose = useAgentSelector(snapshot => {
45
+ const shown = new Set();
46
+ for (const message of snapshot.messages)
47
+ if (message.role === "assistant")
48
+ for (const part of message.parts)
49
+ if (part.type === "tool" && part.input)
50
+ shown.add(part.input.id);
51
+ const rest = snapshot.inputs.filter(input => !shown.has(input.id));
52
+ return rest.length ? rest : EMPTY;
53
+ }, { equal: (a, b) => a.length === b.length && a.every((input, at) => input === b[at]) });
54
+ if (!loose.length)
55
+ return null;
56
+ return _jsx("div", { className: "agent-chat__loose-inputs", children: loose.map(input => _jsx(components.InputCard, { input: input }, input.id)) });
57
+ }
58
+ const EMPTY = [];
59
+ /** The chat's own trouble (a send's shows on its message): it could not start, or the stream is reconnecting. */
60
+ function ErrorBanner() {
61
+ const { labels } = useUI();
62
+ const { error, status, connected } = useAgentStatus();
63
+ if (status === "error" && error)
64
+ return _jsx("div", { className: "agent-chat__error", role: "alert", children: error.message || labels.failed });
65
+ if (error?.code === "stream_error" && !connected)
66
+ return _jsx("div", { className: "agent-chat__error agent-chat__error--soft", role: "status", children: labels.reconnecting });
67
+ return null;
68
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Markdown as the agent writes it, rendered with React elements (never HTML). Blocks that did not
3
+ * change are not rendered again while the last one streams. Images show as links unless the chat
4
+ * allows them (`allowImages`): loading an image the model chose tells its server about the reader.
5
+ */
6
+ export declare const Markdown: import("react").MemoExoticComponent<({ text, streaming, className }: {
7
+ text: string;
8
+ streaming?: boolean;
9
+ className?: string;
10
+ }) => import("react").JSX.Element>;
@@ -0,0 +1,71 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useMemo, useState } from "react";
3
+ import { inlineText, parseMarkdown } from "@camelai/agent-runtime/markdown";
4
+ import { cx, useUI } from "./context.js";
5
+ /**
6
+ * Markdown as the agent writes it, rendered with React elements (never HTML). Blocks that did not
7
+ * change are not rendered again while the last one streams. Images show as links unless the chat
8
+ * allows them (`allowImages`): loading an image the model chose tells its server about the reader.
9
+ */
10
+ export const Markdown = memo(function Markdown({ text, streaming = false, className }) {
11
+ const blocks = useMemo(() => safely(text, streaming), [text, streaming]);
12
+ return _jsx("div", { className: cx("agent-chat__markdown", className), children: blocks.map((block, at) => _jsx(BlockView, { block: block, open: streaming && at === blocks.length - 1 }, at)) });
13
+ });
14
+ /** A block; `open`: the last block of text still streaming (it may parse differently once settled). */
15
+ /** The blocks, or the text as one paragraph should parsing ever fail: a reply always shows. */
16
+ function safely(text, streaming) {
17
+ try {
18
+ return parseMarkdown(text, { streaming });
19
+ }
20
+ catch {
21
+ return [{ type: "paragraph", children: [{ type: "text", text }], raw: text }];
22
+ }
23
+ }
24
+ const BlockView = memo(function BlockView({ block }) {
25
+ switch (block.type) {
26
+ case "paragraph": return _jsx("p", { children: _jsx(Inlines, { nodes: block.children }) });
27
+ case "heading": {
28
+ const Tag = `h${block.level}`;
29
+ return _jsx(Tag, { children: _jsx(Inlines, { nodes: block.children }) });
30
+ }
31
+ case "code": return _jsx(CodeBlock, { lang: block.lang, text: block.text });
32
+ case "blockquote": return _jsx("blockquote", { children: block.children.map((child, at) => _jsx(BlockView, { block: child }, at)) });
33
+ case "list": {
34
+ const items = block.items.map((item, at) => (_jsx("li", { children: item.length === 1 && item[0].type === "paragraph" ? _jsx(Inlines, { nodes: item[0].children }) : item.map((child, index) => _jsx(BlockView, { block: child }, index)) }, at)));
35
+ return block.ordered ? _jsx("ol", { start: block.start === 1 ? undefined : block.start, children: items }) : _jsx("ul", { children: items });
36
+ }
37
+ case "table": return (_jsx("div", { className: "agent-chat__table", children: _jsxs("table", { children: [_jsx("thead", { children: _jsx("tr", { children: block.head.map((cell, at) => _jsx("th", { style: block.align[at] ? { textAlign: block.align[at] } : undefined, children: _jsx(Inlines, { nodes: cell }) }, at)) }) }), _jsx("tbody", { children: block.rows.map((row, index) => _jsx("tr", { children: row.map((cell, at) => _jsx("td", { style: block.align[at] ? { textAlign: block.align[at] } : undefined, children: _jsx(Inlines, { nodes: cell }) }, at)) }, index)) })] }) }));
38
+ case "hr": return _jsx("hr", {});
39
+ }
40
+ }, (a, b) => a.block.raw === b.block.raw && a.block.type === b.block.type && a.open === b.open);
41
+ function Inlines({ nodes }) {
42
+ return _jsx(_Fragment, { children: nodes.map((node, at) => _jsx(InlineView, { node: node }, at)) });
43
+ }
44
+ function InlineView({ node }) {
45
+ const { allowImages } = useUI();
46
+ switch (node.type) {
47
+ case "text": return node.text;
48
+ case "break": return _jsx("br", {});
49
+ case "code": return _jsx("code", { children: node.text });
50
+ case "strong": return _jsx("strong", { children: _jsx(Inlines, { nodes: node.children }) });
51
+ case "em": return _jsx("em", { children: _jsx(Inlines, { nodes: node.children }) });
52
+ case "del": return _jsx("del", { children: _jsx(Inlines, { nodes: node.children }) });
53
+ case "link": return _jsx("a", { href: node.href, target: "_blank", rel: "noopener noreferrer nofollow", children: _jsx(Inlines, { nodes: node.children }) });
54
+ case "image": return allowImages
55
+ ? _jsx("img", { src: node.src, alt: node.alt, loading: "lazy", referrerPolicy: "no-referrer" })
56
+ : _jsx("a", { href: node.src, target: "_blank", rel: "noopener noreferrer nofollow", children: node.alt || inlineText([node]) || node.src });
57
+ }
58
+ }
59
+ function CodeBlock({ lang, text }) {
60
+ const { labels } = useUI();
61
+ const [copied, setCopied] = useState(false);
62
+ const copy = async () => {
63
+ try {
64
+ await navigator.clipboard.writeText(text);
65
+ setCopied(true);
66
+ setTimeout(() => setCopied(false), 1500);
67
+ }
68
+ catch { /* no clipboard */ }
69
+ };
70
+ return (_jsxs("div", { className: "agent-chat__code", children: [_jsxs("div", { className: "agent-chat__code-bar", children: [_jsx("span", { children: lang }), _jsx("button", { type: "button", className: "agent-chat__code-copy", onClick: copy, "aria-label": copied ? labels.copied : `${labels.copy}${lang ? ` ${lang}` : ""}`, children: copied ? labels.copied : labels.copy })] }), _jsx("pre", { children: _jsx("code", { "data-language": lang || undefined, children: text }) })] }));
71
+ }
@@ -0,0 +1,23 @@
1
+ import { type ReactNode } from "react";
2
+ import type { AssistantChatMessage, UserChatMessage } from "@camelai/agent-runtime/chat";
3
+ /** A user's message: its text, who sent it (in a shared chat), and whether it was sent. */
4
+ export declare const UserMessage: import("react").MemoExoticComponent<({ message }: {
5
+ message: UserChatMessage;
6
+ }) => import("react").JSX.Element>;
7
+ /** The agent's turn: its text, reasoning, tool calls, questions and files, in order. */
8
+ export declare const AssistantMessage: import("react").MemoExoticComponent<({ message }: {
9
+ message: AssistantChatMessage;
10
+ }) => import("react").JSX.Element>;
11
+ /**
12
+ * The conversation: follows the reply while you are at the bottom, stays put when you scroll up
13
+ * (with a button back to the latest), and keeps your place when older messages load above.
14
+ */
15
+ export declare function MessageList({ className, children, suggestions }: {
16
+ className?: string;
17
+ children?: ReactNode;
18
+ suggestions?: string[];
19
+ }): import("react").JSX.Element;
20
+ export declare function EmptyState({ suggestions, send }: {
21
+ suggestions: string[];
22
+ send(text: string): void;
23
+ }): import("react").JSX.Element;
@@ -0,0 +1,137 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import { useAgent, useAgentSelector, useAgentStatus, useLoadOlder, useMessages } from "../index.js";
4
+ import { cx, useUI } from "./context.js";
5
+ import { ToolView } from "./tool.js";
6
+ /** A user's message: its text, who sent it (in a shared chat), and whether it was sent. */
7
+ export const UserMessage = memo(function UserMessage({ message }) {
8
+ const { labels } = useUI();
9
+ const chat = useAgent();
10
+ return (_jsxs("div", { className: "agent-chat__message agent-chat__message--user", "data-status": message.status, children: [message.from?.name && _jsx("div", { className: "agent-chat__author", children: message.from.name }), _jsx("div", { className: "agent-chat__bubble", children: message.parts.map(part => part.type === "text"
11
+ ? _jsx("p", { className: "agent-chat__user-text", children: part.text }, part.id)
12
+ : _jsx("img", { src: part.url, alt: "", className: "agent-chat__user-image" }, part.id)) }), message.status === "sending" && _jsx("div", { className: "agent-chat__note", children: labels.sending }), message.status === "failed" && (_jsxs("div", { className: "agent-chat__note agent-chat__note--error", role: "alert", children: [message.error?.message ?? labels.failed, " ", _jsx("button", { type: "button", className: "agent-chat__link-button", onClick: () => void chat.retry(message.id), children: labels.retry })] }))] }));
13
+ });
14
+ /** The agent's turn: its text, reasoning, tool calls, questions and files, in order. */
15
+ export const AssistantMessage = memo(function AssistantMessage({ message }) {
16
+ const { labels } = useUI();
17
+ const last = message.parts.at(-1);
18
+ return (_jsxs("div", { className: "agent-chat__message agent-chat__message--assistant", "data-streaming": message.streaming || undefined, children: [message.parts.map(part => _jsx(Part, { part: part, streaming: message.streaming && part === last }, part.id)), message.stopReason === "aborted" && _jsx("div", { className: "agent-chat__note", children: labels.stopped }), message.stopReason === "error" && _jsx("div", { className: "agent-chat__note agent-chat__note--error", children: message.error ?? labels.failed })] }));
19
+ });
20
+ const Part = memo(function Part({ part, streaming }) {
21
+ const { components, showReasoning, labels } = useUI();
22
+ switch (part.type) {
23
+ case "text": return _jsx(components.Markdown, { text: part.text, streaming: streaming && part.streaming });
24
+ case "reasoning": return showReasoning && !part.redacted ? (_jsxs("details", { className: "agent-chat__reasoning", "data-streaming": part.streaming || undefined, children: [_jsx("summary", { children: part.streaming ? labels.thinking : labels.reasoning }), _jsx("div", { className: "agent-chat__reasoning-text", children: part.text })] })) : null;
25
+ case "tool": return _jsx(ToolView, { part: part });
26
+ case "file": return _jsx(components.FilePreview, { part: part });
27
+ default: return null;
28
+ }
29
+ });
30
+ const Row = memo(function Row({ message }) {
31
+ const { components } = useUI();
32
+ return message.role === "user" ? _jsx(components.UserMessage, { message: message }) : _jsx(components.AssistantMessage, { message: message });
33
+ });
34
+ /** How close to the bottom (px) still counts as at the bottom. */
35
+ const NEAR = 48;
36
+ /**
37
+ * The conversation: follows the reply while you are at the bottom, stays put when you scroll up
38
+ * (with a button back to the latest), and keeps your place when older messages load above.
39
+ */
40
+ export function MessageList({ className, children, suggestions = [] }) {
41
+ const { labels, components } = useUI();
42
+ const chat = useAgent();
43
+ const messages = useMessages();
44
+ const { status } = useAgentStatus();
45
+ const { hasOlder, loading, loadOlder } = useLoadOlder();
46
+ const scroller = useRef(null);
47
+ const content = useRef(null);
48
+ const atBottom = useRef(true);
49
+ const [showJump, setShowJump] = useState(false);
50
+ const anchor = useRef(null);
51
+ const toBottom = useCallback((smooth = false) => {
52
+ const element = scroller.current;
53
+ if (!element)
54
+ return;
55
+ element.scrollTo?.({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" });
56
+ if (!element.scrollTo)
57
+ element.scrollTop = element.scrollHeight;
58
+ atBottom.current = true;
59
+ setShowJump(false);
60
+ }, []);
61
+ const onScroll = () => {
62
+ const element = scroller.current;
63
+ if (!element)
64
+ return;
65
+ atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight <= NEAR;
66
+ setShowJump(!atBottom.current);
67
+ if (element.scrollTop < 64 && hasOlder && !loading)
68
+ void older();
69
+ };
70
+ const older = async () => {
71
+ const element = scroller.current;
72
+ if (element)
73
+ anchor.current = { height: element.scrollHeight, top: element.scrollTop };
74
+ await loadOlder();
75
+ };
76
+ // After older messages land above, keep what was on screen where it was.
77
+ useLayoutEffect(() => {
78
+ const element = scroller.current, kept = anchor.current;
79
+ if (!element || !kept || loading)
80
+ return;
81
+ anchor.current = null;
82
+ element.scrollTop = kept.top + element.scrollHeight - kept.height;
83
+ }, [messages, loading]);
84
+ // Follow new content (and a message just sent) while at the bottom.
85
+ const lastId = messages.at(-1)?.id;
86
+ const lastIsMine = messages.at(-1)?.role === "user";
87
+ useLayoutEffect(() => { if (lastIsMine)
88
+ toBottom(); }, [lastId, lastIsMine, toBottom]);
89
+ useEffect(() => {
90
+ const element = content.current;
91
+ if (!element || typeof ResizeObserver === "undefined")
92
+ return;
93
+ const observer = new ResizeObserver(() => { if (atBottom.current && !anchor.current)
94
+ toBottom(); });
95
+ observer.observe(element);
96
+ return () => observer.disconnect();
97
+ }, [toBottom]);
98
+ useLayoutEffect(() => { if (atBottom.current && !anchor.current)
99
+ toBottom(); }, [messages, toBottom]);
100
+ const waiting = status === "submitted" && !messages.some(message => message.role === "assistant" && message.streaming);
101
+ const empty = !messages.length && status !== "connecting";
102
+ return (_jsxs("div", { className: cx("agent-chat__viewport", className), children: [_jsx("div", { ref: scroller, className: "agent-chat__messages", role: "log", "aria-label": labels.conversation, "aria-live": "off", tabIndex: 0, onScroll: onScroll, children: _jsxs("div", { ref: content, className: "agent-chat__content", children: [hasOlder && (_jsx("button", { type: "button", className: "agent-chat__older", onClick: () => void older(), disabled: loading, children: loading ? labels.loadingOlder : labels.loadOlder })), empty && _jsx(components.EmptyState, { suggestions: suggestions, send: text => void chat.send(text) }), messages.map(message => _jsx(Row, { message: message }, message.id)), waiting && _jsxs("div", { className: "agent-chat__thinking", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {}), _jsx("span", { className: "agent-chat__thinking-text", children: labels.thinking })] }), children] }) }), showJump && (_jsx("button", { type: "button", className: "agent-chat__jump", onClick: () => toBottom(true), children: labels.jumpToLatest })), _jsx(Announcer, {})] }));
103
+ }
104
+ /**
105
+ * Tells screen readers what happens without reading every streamed token: that the assistant is
106
+ * responding, then its finished reply, and when it needs input.
107
+ */
108
+ function Announcer() {
109
+ const { labels } = useUI();
110
+ const status = useAgentSelector(snapshot => snapshot.status);
111
+ const lastReply = useAgentSelector(snapshot => {
112
+ const last = snapshot.messages.at(-1);
113
+ return last?.role === "assistant" && !last.streaming ? last : null;
114
+ });
115
+ const [text, setText] = useState("");
116
+ const previous = useRef(status);
117
+ const seen = useRef(lastReply?.id);
118
+ useEffect(() => {
119
+ const was = previous.current;
120
+ previous.current = status;
121
+ if (status === "streaming" && was !== "streaming")
122
+ setText(labels.responding);
123
+ else if (status === "input_required" && was !== "input_required")
124
+ setText(labels.needsInput);
125
+ else if (status === "ready" && lastReply && (was === "streaming" || was === "submitted") && seen.current !== lastReply.id + lastReply.parts.length) {
126
+ seen.current = lastReply.id + lastReply.parts.length;
127
+ const reply = lastReply.parts.flatMap(part => part.type === "text" ? [part.text] : []).join("\n").slice(0, 1000);
128
+ if (reply)
129
+ setText(`${labels.replied} ${reply}`);
130
+ }
131
+ }, [status, lastReply, labels]);
132
+ return _jsx("div", { className: "agent-chat__sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true", children: text });
133
+ }
134
+ export function EmptyState({ suggestions, send }) {
135
+ const { labels } = useUI();
136
+ return (_jsxs("div", { className: "agent-chat__empty", children: [_jsx("p", { className: "agent-chat__empty-title", children: labels.emptyTitle }), suggestions.length > 0 && (_jsx("ul", { className: "agent-chat__suggestions", children: suggestions.map(suggestion => _jsx("li", { children: _jsx("button", { type: "button", className: "agent-chat__suggestion", onClick: () => send(suggestion), children: suggestion }) }, suggestion)) }))] }));
137
+ }
@@ -0,0 +1,13 @@
1
+ import type { ChatInput, ToolPart } from "@camelai/agent-runtime/chat";
2
+ /** A tool call: its renderer (generative UI) if it has one, what it waits on, or the default card. */
3
+ export declare const ToolView: import("react").MemoExoticComponent<({ part }: {
4
+ part: ToolPart;
5
+ }) => import("react").JSX.Element>;
6
+ /** The default card for a tool call: what it was called with, its progress, and its result. */
7
+ export declare function ToolCard({ part }: {
8
+ part: ToolPart;
9
+ }): import("react").JSX.Element;
10
+ /** What the agent waits on: an approval, questions, a form, or a page to visit. */
11
+ export declare const InputCard: import("react").MemoExoticComponent<({ input }: {
12
+ input: ChatInput;
13
+ }) => import("react").JSX.Element>;