diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 3f7570a17f..4a2c177a9f 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -27,6 +27,19 @@ const config: Config = { favicon: "img/docusaurus/favicon-96x96.png", organizationName: "stellar", projectName: "stellar-docs", + customFields: { + // Algolia Agent Studio connection for the docs AI assistant. + // The search-only API key is safe to expose client-side. Override any of + // these with env vars at build time. + agentChat: { + appId: process.env.AGENT_CHAT_APP_ID || "VNSJF5AWIZ", + agentId: + process.env.AGENT_CHAT_AGENT_ID || + "bf6c7577-d445-4132-9ad8-c349a0560621", + apiKey: + process.env.AGENT_CHAT_API_KEY || "86acea3d3ee70d36299b99fda5fdf092", + }, + }, // i18n: { // defaultLocale: DEFAULT_LOCALE, // locales: ["en", "es"], diff --git a/package.json b/package.json index b64226f603..108ce6d502 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "diff:mdx": "yarn ci-format:mdx && git diff -- . ':(exclude)package-lock.json' ':(exclude)package.json' ':(exclude)yarn.lock' | awk \"/diff --git/ {found=1} found {print}\"", "lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix", "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", + "test": "vitest run src/agent-chat", "prepare": "husky", "postinstall": "patch-package", "rpcspec:build": "node openrpc/scripts/build.mjs", @@ -37,6 +38,7 @@ "crowdin:sync": "docusaurus write-translations && crowdin upload --no-progress --delete-obsolete && crowdin download --no-progress" }, "dependencies": { + "@ai-sdk/react": "2.0.196", "@docusaurus/core": "3.9.2", "@docusaurus/faster": "3.9.2", "@docusaurus/preset-classic": "3.9.2", @@ -46,6 +48,7 @@ "@open-rpc/meta-schema": "^1.14.9", "@open-rpc/schema-utils-js": "^1.16.2", "@stellar/open-rpc-docs-react": "^0.2.1", + "ai": "5.0.194", "ajv": "^8.18.0", "clsx": "^2.1.1", "docusaurus-plugin-openapi-docs": "4.5.1", @@ -57,7 +60,9 @@ "prism-react-renderer": "^2.4.1", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "10.1.0", "rehype-katex": "^7.0.1", + "remark-gfm": "4.0.1", "remark-math": "^6.0.0", "sass": "^1.98.0" }, @@ -87,7 +92,8 @@ "glob": "^13.0.6", "husky": "^9.1.7", "prettier": "3.8.1", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "^4.1.8" }, "engines": { "node": ">=24" diff --git a/routes.txt b/routes.txt index b908818d18..7d085b72cd 100644 --- a/routes.txt +++ b/routes.txt @@ -1,5 +1,6 @@ / /404.html +/ai /docs /docs/build /docs/build/agentic-payments diff --git a/src/agent-chat/README.md b/src/agent-chat/README.md new file mode 100644 index 0000000000..9c5647e7c5 --- /dev/null +++ b/src/agent-chat/README.md @@ -0,0 +1,79 @@ +# agent-chat + +A small, reusable React chat UI for an +[Algolia Agent Studio](https://www.algolia.com/doc/guides/algolia-ai/agent-studio/) +agent, built on the [Vercel AI SDK](https://ai-sdk.dev/) (`useChat` + +`DefaultChatTransport`). + +It is intentionally **host-agnostic**: nothing here imports Docusaurus. The +folder can be lifted into a standalone package (e.g. `@stellar/agent-chat`) by +copying it and adding a `package.json`. + +## Layout + +``` +core/ # no React/Docusaurus deps — transport + config types +react/ # React UI: useAgentChat hook, AgentChat, AgentChatWidget, CodeBlock, CopyButton, Markdown +index.ts # public exports +theme-fallback.css # optional CSS vars for non-Docusaurus hosts +``` + +## Usage + +```tsx +import { AgentChat } from "@site/src/agent-chat"; + +; +``` + +Floating launcher variant: + +```tsx +import { AgentChatWidget } from "@site/src/agent-chat"; + +; +``` + +## Key props (`AgentChat`) + +| Prop | Purpose | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `config` | `{ appId, agentId, apiKey, host?, stream? }` connection details. | +| `welcomeMessage` | Shown on the empty state. | +| `suggestions` | Starter prompt buttons. | +| `placeholder` | Input placeholder. | +| `markdownClassName` | Class applied to rendered answers so they inherit the host's prose styling (e.g. Docusaurus `"markdown"`). | +| `codeBlock` | Component used for fenced code. Defaults to a built-in copyable block; pass the host's highlighter (e.g. Docusaurus `@theme/CodeBlock`) for syntax highlighting. | +| `hideStatusUntilToolCall` | Hide a streaming reply until a tool call occurs, suppressing pre-search "status" chatter. Defaults to `true`. Set `false` for agents that answer without tools. | + +`AgentChatWidget` accepts all of the above plus `title`. + +## Theming + +The UI reads **Infima CSS variables** (`--ifm-*`), so inside a Docusaurus site +it matches the theme (including light/dark) automatically. + +- **Accent color**: set `--agent-chat-accent` and `--agent-chat-accent-contrast` + (defaults to Stellar gold `#fdda24` on near-black). Used for the launcher, + header, user bubbles, and send button. +- **Non-Docusaurus hosts**: import `theme-fallback.css` once to supply the + `--ifm-*` variables, then override as needed. + +## Notes for extraction into a standalone package + +- Declared runtime deps: `react`, `ai`, `@ai-sdk/react`, `react-markdown`, + `remark-gfm`, `clsx`. +- Pin `ai`/`@ai-sdk/react` to the v5 line to match the endpoint's + `compatibilityMode=ai-sdk-5`. +- The `react/` components must run client-side (they use the clipboard and + streaming). In SSR frameworks, render them in a browser-only boundary (e.g. + Docusaurus ``). +- `core/` has no React dependency and can be used on its own to build requests. diff --git a/src/agent-chat/core/createAgentTransport.test.ts b/src/agent-chat/core/createAgentTransport.test.ts new file mode 100644 index 0000000000..5042fbfd76 --- /dev/null +++ b/src/agent-chat/core/createAgentTransport.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import type { UIMessage } from "ai"; + +import { buildCompletionsUrl, sanitizeMessages } from "./createAgentTransport"; + +const CONFIG = { + appId: "VNSJF5AWIZ", + agentId: "bf6c7577-d445-4132-9ad8-c349a0560621", + apiKey: "key", +}; + +function msg(role: "user" | "assistant", parts: unknown[]): UIMessage { + return { id: "m1", role, parts } as UIMessage; +} + +describe("buildCompletionsUrl", () => { + it("targets the Agent Studio endpoint with ai-sdk-5 compatibility and streaming", () => { + expect(buildCompletionsUrl(CONFIG)).toBe( + "https://vnsjf5awiz.algolia.net/agent-studio/1/agents/bf6c7577-d445-4132-9ad8-c349a0560621/completions?compatibilityMode=ai-sdk-5&stream=true", + ); + }); + + it("honors host and stream overrides", () => { + expect( + buildCompletionsUrl({ ...CONFIG, host: "https://example.com", stream: false }), + ).toBe( + "https://example.com/agent-studio/1/agents/bf6c7577-d445-4132-9ad8-c349a0560621/completions?compatibilityMode=ai-sdk-5&stream=false", + ); + }); +}); + +describe("sanitizeMessages", () => { + // Regression: a failed tool call (state output-error) in history made Agent + // Studio emit a `tool_use` block without a `tool_result`, so the LLM rejected + // the whole conversation and every later turn failed. + it("rewrites errored tool parts into regular outputs carrying the error", () => { + const messages = [ + msg("assistant", [ + { type: "text", text: "Looking that up…" }, + { + type: "tool-getPage", + toolCallId: "toolu_123", + state: "output-error", + input: { url: "/docs" }, + errorText: "fetch failed", + }, + { type: "text", text: "Here is the answer." }, + ]), + ]; + + const [out] = sanitizeMessages(messages); + const tool = out.parts[1] as Record; + expect(tool.state).toBe("output-available"); + expect(tool.output).toEqual({ error: "fetch failed" }); + expect(tool).not.toHaveProperty("errorText"); + // Non-tool parts pass through untouched. + expect(out.parts[0]).toEqual({ type: "text", text: "Looking that up…" }); + expect(out.parts[2]).toEqual({ type: "text", text: "Here is the answer." }); + }); + + it("defaults the output error text when errorText is missing", () => { + const [out] = sanitizeMessages([ + msg("assistant", [ + { type: "dynamic-tool", toolCallId: "t1", state: "output-error", input: {} }, + ]), + ]); + expect((out.parts[0] as Record).output).toEqual({ + error: "Tool call failed.", + }); + }); + + it("drops tool parts that never produced an output (stopped streams)", () => { + const [out] = sanitizeMessages([ + msg("assistant", [ + { type: "tool-search", toolCallId: "t1", state: "input-streaming" }, + { type: "tool-search", toolCallId: "t2", state: "input-available", input: {} }, + { type: "text", text: "answer" }, + ]), + ]); + expect(out.parts).toEqual([{ type: "text", text: "answer" }]); + }); + + it("passes successful tool parts and full conversations through unchanged", () => { + const messages = [ + msg("user", [{ type: "text", text: "What is a trustline?" }]), + msg("assistant", [ + { type: "step-start" }, + { + type: "tool-algolia_search_index_docs_replica_agent", + toolCallId: "t1", + state: "output-available", + input: { query: "trustline" }, + output: { hits: [] }, + }, + { type: "text", text: "A trustline is…" }, + ]), + msg("user", [{ type: "text", text: "How do I create one?" }]), + ]; + expect(sanitizeMessages(messages)).toEqual(messages); + }); +}); diff --git a/src/agent-chat/core/createAgentTransport.ts b/src/agent-chat/core/createAgentTransport.ts new file mode 100644 index 0000000000..d3c5612c21 --- /dev/null +++ b/src/agent-chat/core/createAgentTransport.ts @@ -0,0 +1,82 @@ +import { DefaultChatTransport, type UIMessage } from "ai"; + +import type { AgentChatConfig } from "./types"; + +type AnyPart = UIMessage["parts"][number]; + +function isToolPart(part: AnyPart): boolean { + return part.type.startsWith("tool-") || part.type === "dynamic-tool"; +} + +/** + * Agent Studio replays UI message history to the underlying LLM, but it drops + * tool parts that never produced an output (state `output-error` from a failed + * tool run, or `input-*` after a stopped stream) without dropping the matching + * `tool_use` block. The LLM then rejects the whole conversation ("`tool_use` + * ids were found without `tool_result` blocks"), and every later turn fails. + * + * Work around it client-side: rewrite errored tool parts so the error text + * becomes a regular output (keeping that context for the model), and drop tool + * parts that have no input/output to pair up at all. + */ +export function sanitizeMessages(messages: UIMessage[]): UIMessage[] { + return messages.map((message) => ({ + ...message, + parts: message.parts + .filter( + (part) => + !isToolPart(part) || + ("state" in part && + (part.state === "output-available" || + part.state === "output-error")), + ) + .map((part) => { + if (isToolPart(part) && "state" in part && part.state === "output-error") { + const { errorText, ...rest } = part as typeof part & { + errorText?: string; + }; + return { + ...rest, + state: "output-available", + output: { error: errorText ?? "Tool call failed." }, + } as AnyPart; + } + return part; + }), + })); +} + +/** + * Build the Agent Studio completions endpoint URL for a given agent. + * + * Uses `compatibilityMode=ai-sdk-5` to match the AI SDK v5 UI message stream + * protocol, and `stream=true` so `useChat` receives a streamable response. + */ +export function buildCompletionsUrl(config: AgentChatConfig): string { + const host = + config.host ?? `https://${config.appId.toLowerCase()}.algolia.net`; + const params = new URLSearchParams({ + compatibilityMode: "ai-sdk-5", + stream: String(config.stream ?? true), + }); + return `${host}/agent-studio/1/agents/${config.agentId}/completions?${params.toString()}`; +} + +/** + * Create an AI SDK transport pointed at an Agent Studio agent. + * + * Pass the result to `useChat({ transport })` (or use the `useAgentChat` hook, + * which does this for you). + */ +export function createAgentTransport(config: AgentChatConfig) { + return new DefaultChatTransport({ + api: buildCompletionsUrl(config), + headers: { + "x-algolia-application-id": config.appId, + "x-algolia-api-key": config.apiKey, + }, + prepareSendMessagesRequest: ({ id, messages, body }) => ({ + body: { ...body, id, messages: sanitizeMessages(messages) }, + }), + }); +} diff --git a/src/agent-chat/core/types.ts b/src/agent-chat/core/types.ts new file mode 100644 index 0000000000..be044aad89 --- /dev/null +++ b/src/agent-chat/core/types.ts @@ -0,0 +1,25 @@ +/** + * Configuration for connecting to an Algolia Agent Studio agent. + * + * This module is framework-agnostic on purpose: nothing here imports React or + * Docusaurus, so the whole `agent-chat` folder can be lifted into a standalone + * package (e.g. `@stellar/agent-chat`) later without changes. + */ +export interface AgentChatConfig { + /** Algolia application ID (e.g. `VNSJF5AWIZ`). */ + appId: string; + /** Published Agent Studio agent ID. */ + agentId: string; + /** Search-only API key. Safe to expose client-side. */ + apiKey: string; + /** + * Override the Algolia host. Defaults to `https://{appId}.algolia.net`. + * Useful for testing or non-standard clusters. + */ + host?: string; + /** + * Stream responses token-by-token. Defaults to `true`, which is required for + * the AI SDK `useChat` transport to read a UI message stream. + */ + stream?: boolean; +} diff --git a/src/agent-chat/index.ts b/src/agent-chat/index.ts new file mode 100644 index 0000000000..3136643190 --- /dev/null +++ b/src/agent-chat/index.ts @@ -0,0 +1,26 @@ +/** + * Reusable Algolia Agent Studio chat module. + * + * Designed to be host-agnostic: the `core/` layer has no React/Docusaurus + * dependency, and the `react/` layer only depends on React + the AI SDK. To + * reuse elsewhere, copy this folder and add a `package.json`. + */ +export { + buildCompletionsUrl, + createAgentTransport, +} from "./core/createAgentTransport"; +export type { AgentChatConfig } from "./core/types"; + +export { useAgentChat } from "./react/useAgentChat"; +export { AgentChat, type AgentChatProps } from "./react/AgentChat"; +export { + AgentChatWidget, + type AgentChatWidgetProps, +} from "./react/AgentChatWidget"; +export { + Markdown, + type MarkdownProps, + type CodeBlockComponent, +} from "./react/Markdown"; +export { CodeBlock, type CodeBlockProps } from "./react/CodeBlock"; +export { CopyButton, type CopyButtonProps } from "./react/CopyButton"; diff --git a/src/agent-chat/react/AgentChat.module.css b/src/agent-chat/react/AgentChat.module.css new file mode 100644 index 0000000000..9b9d354872 --- /dev/null +++ b/src/agent-chat/react/AgentChat.module.css @@ -0,0 +1,288 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); +} + +.messages { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.empty { + margin: auto 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.welcome { + margin: 0; + color: var(--ifm-color-emphasis-700); + font-size: 0.95rem; + line-height: 1.5; +} + +.suggestions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.suggestion { + text-align: left; + border: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-surface-color); + color: var(--ifm-font-color-base); + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease; +} + +.suggestion:hover { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-emphasis-100); +} + +.message { + display: flex; +} + +.userMessage { + justify-content: flex-end; +} + +.assistantMessage { + justify-content: flex-start; +} + +.bubble { + padding: 0.625rem 0.875rem; + border-radius: 1rem; + font-size: 0.9rem; + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: anywhere; +} + +.userMessage .bubble { + max-width: 85%; + background: var(--agent-chat-accent, #fdda24); + color: var(--agent-chat-accent-contrast, #0f0f0f); + border-bottom-right-radius: 0.25rem; + white-space: pre-wrap; +} + +.assistantMessage .bubble { + max-width: 100%; + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-200); + color: var(--ifm-font-color-base); + border-bottom-left-radius: 0.25rem; +} + +.bubbleActions { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} + +/* Tame the inherited site prose styles so markdown reads compactly in the + narrow chat panel (the host's `.markdown` class is applied alongside). */ +.prose { + font-size: 0.9rem; +} + +.prose > :first-child { + margin-top: 0; +} + +.prose > :last-child { + margin-bottom: 0; +} + +.prose :is(h1, h2, h3, h4, h5, h6) { + font-size: 1rem; + font-weight: 600; + margin: 0.75rem 0 0.35rem; + line-height: 1.3; +} + +.prose p, +.prose ul, +.prose ol { + margin-bottom: 0.5rem; +} + +.prose ul, +.prose ol { + padding-left: 1.25rem; +} + +.prose hr { + margin: 0.75rem 0; +} + +.prose pre { + font-size: 0.8rem; +} + +/* Keep wide content (tables, code) from forcing horizontal page scroll. */ +.prose table { + display: block; + width: 100%; + overflow-x: auto; +} + +.prose img { + max-width: 100%; + height: auto; +} + +.thinking { + display: inline-flex; + gap: 0.25rem; + align-items: center; +} + +.thinking span { + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + background: var(--ifm-color-emphasis-600); + animation: blink 1.4s infinite both; +} + +.thinking span:nth-child(2) { + animation-delay: 0.2s; +} + +.thinking span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes blink { + 0%, + 80%, + 100% { + opacity: 0.25; + } + 40% { + opacity: 1; + } +} + +.error { + align-self: center; + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; + color: var(--ifm-color-danger); + font-size: 0.85rem; +} + +/* Dev-only raw error text rendered under the banner (stripped in prod). */ +.errorDetail { + flex-basis: 100%; + margin: 0; + padding: 0.5rem; + max-height: 8rem; + overflow: auto; + font-size: 0.7rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + border: 1px dashed var(--ifm-color-danger); + border-radius: 0.35rem; + background: var(--ifm-background-surface-color); + color: var(--ifm-color-danger); +} + +.retry { + padding: 0.2rem 0.6rem; + font-size: 0.78rem; + border: 1px solid currentColor; + border-radius: 0.35rem; + background: transparent; + color: inherit; + cursor: pointer; +} + +.inputRow { + display: flex; + gap: 0.5rem; + align-items: flex-end; + padding: 0.75rem; + border-top: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-surface-color); +} + +.input { + flex: 1 1 auto; + resize: none; + max-height: 8rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 0.75rem; + padding: 0.5rem 0.75rem; + font: inherit; + font-size: 0.9rem; + line-height: 1.4; + color: var(--ifm-font-color-base); + background: var(--ifm-background-color); +} + +.input:focus { + outline: none; + border-color: var(--ifm-color-primary); +} + +.sendButton { + flex: 0 0 auto; + width: 2.25rem; + height: 2.25rem; + border: none; + border-radius: 50%; + background: var(--agent-chat-accent, #fdda24); + color: var(--agent-chat-accent-contrast, #0f0f0f); + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.sendButton:disabled { + opacity: 0.4; + cursor: default; +} + +@media (max-width: 600px) { + /* 16px prevents iOS Safari from auto-zooming when the input is focused. */ + .input { + font-size: 16px; + } + + .sendButton { + width: 2.5rem; + height: 2.5rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .thinking span { + animation: none; + } +} diff --git a/src/agent-chat/react/AgentChat.tsx b/src/agent-chat/react/AgentChat.tsx new file mode 100644 index 0000000000..8dfcdb446d --- /dev/null +++ b/src/agent-chat/react/AgentChat.tsx @@ -0,0 +1,288 @@ +import React, { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import type { UIMessage } from "ai"; + +import { useAgentChat } from "./useAgentChat"; +import { Markdown, type CodeBlockComponent } from "./Markdown"; +import { CopyButton } from "./CopyButton"; +import { textParts, visibleAnswer } from "./messageParts"; +import type { AgentChatConfig } from "../core/types"; +import styles from "./AgentChat.module.css"; + +export interface AgentChatProps { + /** Agent Studio connection config. */ + config: AgentChatConfig; + /** Extra class on the root container. */ + className?: string; + /** Placeholder for the input. */ + placeholder?: string; + /** Message shown before the conversation starts. */ + welcomeMessage?: string; + /** Optional starter prompts shown on the empty state. */ + suggestions?: string[]; + /** + * Class applied to rendered assistant markdown so it can inherit the host + * site's prose styling (e.g. Docusaurus's `markdown` class). Host-agnostic: + * pass nothing for unstyled output. + */ + markdownClassName?: string; + /** + * Component used to render fenced code blocks. Defaults to a built-in + * copyable block; pass a host component (e.g. Docusaurus `@theme/CodeBlock`) + * for syntax highlighting that matches the site. + */ + codeBlock?: CodeBlockComponent; + /** + * While an assistant message is streaming, hide its text until a tool call + * has occurred. This suppresses the "Let me find that…" status chatter that + * tool-using agents emit before each search. Defaults to `true`. + * + * Set to `false` for agents that answer **without** calling tools, otherwise + * their reply won't appear until streaming finishes. + */ + hideStatusUntilToolCall?: boolean; +} + +function Message({ + message, + markdownClassName, + codeBlock, + streaming, + hideStatusUntilToolCall, +}: { + message: UIMessage; + markdownClassName?: string; + codeBlock?: CodeBlockComponent; + streaming: boolean; + hideStatusUntilToolCall: boolean; +}) { + const isUser = message.role === "user"; + const segments = isUser + ? textParts(message) + : visibleAnswer(message, streaming, hideStatusUntilToolCall); + if (segments.length === 0) { + return null; + } + return ( +
+
+ {isUser ? ( + segments.join("\n\n") + ) : ( + <> +
+ {segments.map((segment, index) => ( + + {segment} + + ))} +
+ {!streaming && ( +
+ +
+ )} + + )} +
+
+ ); +} + +/** + * Headless-ish chat UI for an Agent Studio agent. Renders a scrollable message + * list and an input. Styling is driven by Infima CSS variables so it adapts to + * the host site's light/dark theme automatically. + */ +export function AgentChat({ + config, + className, + placeholder = "Ask a question…", + welcomeMessage, + suggestions, + markdownClassName, + codeBlock, + hideStatusUntilToolCall = true, +}: AgentChatProps) { + const { messages, sendMessage, status, error, stop, regenerate } = + useAgentChat(config); + const [input, setInput] = useState(""); + const scrollRef = useRef(null); + // Whether the user was at the bottom of the log before the latest update. + const atBottomRef = useRef(true); + + const isBusy = status === "submitted" || status === "streaming"; + const isEmpty = messages.length === 0; + + // Always log the underlying error so bug reports include the real cause; + // the visible banner stays generic for users. + useEffect(() => { + if (error) { + console.error("[agent-chat] Chat request failed:", error); + } + }, [error]); + + const handleScroll = () => { + const el = scrollRef.current; + if (el) { + atBottomRef.current = + el.scrollHeight - el.scrollTop - el.clientHeight < 60; + } + }; + + // Auto-scroll on new content, but only if the user hasn't scrolled up to + // read earlier messages. + useEffect(() => { + const el = scrollRef.current; + if (el && atBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [messages, status]); + + const submit = (text: string) => { + const trimmed = text.trim(); + if (!trimmed || isBusy) { + return; + } + sendMessage({ text: trimmed }); + setInput(""); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submit(input); + } + }; + + // The last assistant message is the one actively streaming while busy. + const last = messages[messages.length - 1]; + const streamingId = + isBusy && last && last.role === "assistant" ? last.id : undefined; + + // Show a thinking indicator while in flight and no answer text is visible yet + // (covers: awaiting first token, and running tools before the answer starts). + const showThinking = + isBusy && + (!last || + last.role === "user" || + visibleAnswer(last, true, hideStatusUntilToolCall).length === 0); + + return ( +
+
+ {isEmpty && ( +
+ {welcomeMessage && ( +

{welcomeMessage}

+ )} + {suggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} +
+ )} + + {messages.map((message) => ( + + ))} + + {showThinking && ( +
+
+ + + +
+
+ )} + + {error && ( +
+ Something went wrong. + + {process.env.NODE_ENV !== "production" && ( +
+                {error.message || String(error)}
+              
+ )} +
+ )} +
+ +
{ + event.preventDefault(); + submit(input); + }} + > +