diff --git a/.changeset/tool-activity-blocks.md b/.changeset/tool-activity-blocks.md new file mode 100644 index 0000000..417c271 --- /dev/null +++ b/.changeset/tool-activity-blocks.md @@ -0,0 +1,9 @@ +--- +'@smooai/chat-widget': minor +--- + +Add an opt-in `showToolActivity` option that renders the agent's tool activity (grep / read_file / bash / knowledge_search…) as inline chips interleaved with its prose, mirroring the smooth daemon SPA's `blocks` model. + +- Defaults to **`false`** — a customer-facing support widget stays byte-for-byte unchanged (prose bubble only). Enable via the `show-tool-activity` HTML attribute or `showToolActivity: true` in the config. +- When enabled, an assistant turn that invokes a tool renders as an ordered strip of prose bubbles + tool chips (running… / done ✓ / error), in the order the model produced them. Tool activity is read from `state.rawResponse.toolCall` / `state.rawResponse.toolResult` (the correct nested path — reading `state.toolResult` leaves chips stuck "running…"). +- Tool name/args are rendered via `textContent` (never `innerHTML`), so a tool payload can't inject markup. New public `MessageBlock` / `ToolCall` types. diff --git a/README.md b/README.md index e4c0f4e..df128b7 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ Declarative attributes mirror the programmatic [`ChatWidgetConfig`](./src/config | `mode` | `mode` | `popover` (default) or `fullpage`. | | `start-open` | `startOpen` | Open the panel immediately (no launcher click). | | `hide-branding` | `hideBranding` | Hide the "powered by smooth-operator" tag + footer link. Default shown. | +| `show-tool-activity` | `showToolActivity` | Show the agent's tool activity as inline chips interleaved with its prose. Default **off** (end-users normally shouldn't see raw tool calls). | | — | `theme` | Brand colors (see [Theming](#theming)). | | — | `userName` / `userEmail` | Optional participant identity. | diff --git a/src/config.test.ts b/src/config.test.ts index 0c39105..2c45ff6 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,6 +11,9 @@ describe('resolveConfig', () => { expect(r.placeholder).toBe('Type a message…'); expect(r.greeting).toBe('Hi! How can I help you today?'); expect(r.startOpen).toBe(false); + // Tool activity is hidden by default — a customer-facing widget shouldn't + // expose raw tool calls to an end-user unless explicitly opted in. + expect(r.showToolActivity).toBe(false); expect(r.theme.primary).toBe('#00a6a6'); // The redesign defaults the border to a translucent value for the glass look. expect(r.theme.border).toBe('rgba(255, 255, 255, 0.1)'); @@ -22,6 +25,10 @@ describe('resolveConfig', () => { expect(r.agentId).toBe('agent-1'); }); + it('honors showToolActivity when opted in', () => { + expect(resolveConfig({ ...base, showToolActivity: true }).showToolActivity).toBe(true); + }); + it('derives userBubble / userBubbleText from primary when unset', () => { const r = resolveConfig({ ...base, theme: { primary: '#ff0000', primaryText: '#fefefe' } }); expect(r.theme.userBubble).toBe('#ff0000'); diff --git a/src/config.ts b/src/config.ts index dfd9952..39ce2a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -141,6 +141,16 @@ export interface ChatWidgetConfig { * `require*` flags are ignored and the pre-chat form is skipped. */ allowAnonymous?: boolean; + /** + * Show the agent's tool activity (grep / read_file / bash / knowledge_search…) + * as inline chips interleaved with its prose, mirroring the smooth daemon SPA. + * + * Defaults to **`false`**: for a customer-facing support widget, surfacing raw + * tool calls to an end-user is usually undesirable, so tool activity is hidden + * and only the assistant's prose renders. Enable it for internal / power-user + * surfaces where seeing what the agent did mid-turn is valuable. + */ + showToolActivity?: boolean; /** Theme overrides. */ theme?: ChatWidgetTheme; } @@ -194,6 +204,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig { collectConsent: config.collectConsent ?? true, allowChatRestore: config.allowChatRestore ?? true, allowAnonymous: config.allowAnonymous ?? false, + showToolActivity: config.showToolActivity ?? false, theme: { text: theme.text ?? '#f8fafc', background: theme.background ?? '#040d30', diff --git a/src/conversation-tool-blocks.test.ts b/src/conversation-tool-blocks.test.ts new file mode 100644 index 0000000..cc5fbc7 --- /dev/null +++ b/src/conversation-tool-blocks.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for interleaved tool-activity blocks in the ConversationController. + * + * Drives `send()` with a scripted turn (a thenable async-iterable of ServerEvents, + * mirroring `@smooai/smooth-operator`'s turn handle) and asserts the emitted + * `ChatMessage.blocks` — verifying the gate (`showToolActivity`), the interleave + * order, tool resolution from the correct `state.rawResponse.toolResult` path, and + * the wrong-path guard. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mock the operator client. `send()` uses: new SmoothAgentClient(), connect(), +// createConversationSession() → {sessionId}, sendMessage() → turn handle. +const scriptedEvents: unknown[] = []; +let scriptedFinal: unknown = { data: { data: { response: { responseParts: [] } } } }; + +function makeTurn() { + return { + requestId: 'r1', + async *[Symbol.asyncIterator]() { + for (const ev of scriptedEvents) yield ev; + }, + then(onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) { + return Promise.resolve(scriptedFinal).then(onFulfilled, onRejected); + }, + }; +} + +vi.mock('@smooai/smooth-operator', () => ({ + ProtocolError: class ProtocolError extends Error {}, + SmoothAgentClient: class { + connect = vi.fn(async () => {}); + createConversationSession = vi.fn(async () => ({ sessionId: 's1' })); + sendMessage = vi.fn(() => makeTurn()); + disconnect = vi.fn(); + }, +})); + +import { type ChatMessage, ConversationController } from './conversation.js'; + +const token = (t: string) => ({ type: 'stream_token', token: t }); +const toolCall = (name: string, args: unknown) => ({ type: 'stream_chunk', data: { state: { rawResponse: { toolCall: { name, arguments: args } } } } }); +const toolResult = (name: string, result: unknown, isError = false) => ({ + type: 'stream_chunk', + data: { state: { rawResponse: { toolResult: { name, isError, result } } } }, +}); + +/** Run a full turn and return the final assistant message. */ +async function runTurn(showToolActivity: boolean, events: unknown[]): Promise { + scriptedEvents.length = 0; + scriptedEvents.push(...events); + let latest: ChatMessage[] = []; + const controller = new ConversationController({ endpoint: 'wss://e/ws', agentId: 'a1', showToolActivity }, { onMessages: (m) => (latest = m), onStatus: () => {} }); + await controller.send('hi'); + return latest.filter((m) => m.role === 'assistant').at(-1)!; +} + +describe('ConversationController tool-activity blocks', () => { + beforeEach(() => { + scriptedFinal = { data: { data: { response: { responseParts: ['done'] } } } }; + }); + + it('does not populate blocks when showToolActivity is off (default behavior unchanged)', async () => { + const msg = await runTurn(false, [token('Hello '), toolCall('grep', { q: 'x' }), toolResult('grep', 'ok'), token('there')]); + expect(msg.blocks).toBeUndefined(); + expect(msg.text).toBe('Hello there'); + }); + + it('interleaves prose and tool chips in order when enabled', async () => { + const msg = await runTurn(true, [token('Let me look. '), toolCall('search', { q: 'x' }), toolResult('search', 'found'), token('Got it.')]); + expect(msg.blocks?.map((b) => b.kind)).toEqual(['text', 'tool', 'text']); + const tool = msg.blocks!.find((b) => b.kind === 'tool')!; + expect(tool).toMatchObject({ kind: 'tool', tool: { name: 'search', done: true, isError: false, result: 'found' } }); + }); + + it('drops blocks for a prose-only turn even when enabled (no tool invoked)', async () => { + const msg = await runTurn(true, [token('Just talking, no tools.')]); + expect(msg.blocks).toBeUndefined(); + }); + + it('marks a tool errored when the result is an error', async () => { + const msg = await runTurn(true, [toolCall('bash', 'ls /nope'), toolResult('bash', 'no such dir', true)]); + const tool = msg.blocks!.find((b) => b.kind === 'tool')!; + expect(tool).toMatchObject({ kind: 'tool', tool: { done: true, isError: true } }); + }); + + it('leaves a chip running when the result is (wrongly) at state.toolResult', async () => { + // Guards the daemon's hard-won bug: results live at rawResponse.toolResult. + const wrongPathResult = { type: 'stream_chunk', data: { state: { toolResult: { name: 'grep', result: 'x' } } } }; + const msg = await runTurn(true, [toolCall('grep', {}), wrongPathResult]); + const tool = msg.blocks!.find((b) => b.kind === 'tool')!; + expect(tool).toMatchObject({ kind: 'tool', tool: { done: false } }); + }); +}); diff --git a/src/conversation.ts b/src/conversation.ts index ff5946f..30ba300 100644 --- a/src/conversation.ts +++ b/src/conversation.ts @@ -61,6 +61,32 @@ export type { Citation }; export type Role = 'user' | 'assistant'; +/** + * One tool invocation within an assistant turn. Mirrors the smooth daemon SPA's + * `ToolCall` (`crates/smooth-web/web/src/operator.ts`): opens `done: false` on the + * tool call and resolves on the tool result. + */ +export interface ToolCall { + /** Stable id for keyed rendering (assigned when the call opens). */ + id: string; + name: string; + /** Raw arguments, JSON-stringified. */ + args: string; + /** Present once the tool resolves. */ + result?: string; + isError?: boolean; + done: boolean; +} + +/** + * One ordered segment of an assistant turn: a run of prose, or a tool call. + * Preserves the interleave order the model produced (say a bit → call a tool → + * say a bit → …) so the UI can render tool chips INLINE where the model called + * them. Mirrors the daemon SPA's `MessageBlock`. Only populated when the widget + * is configured with `showToolActivity: true`. + */ +export type MessageBlock = { kind: 'text'; text: string } | { kind: 'tool'; tool: ToolCall }; + export interface ChatMessage { id: string; role: Role; @@ -68,6 +94,12 @@ export interface ChatMessage { text: string; /** True while an assistant message is still streaming. */ streaming: boolean; + /** + * Ordered text + tool segments, interleaved as the model produced them. Present + * only on assistant messages when `showToolActivity` is enabled (absent + * otherwise — the default popover renders `text` alone, byte-for-byte unchanged). + */ + blocks?: MessageBlock[]; /** * Sources that grounded an assistant answer, when the terminal * `eventual_response` carried any. Optional + back-compatible: absent when @@ -244,6 +276,52 @@ function wireMessageToChat(m: WireMessage, idx: number): ChatMessage | null { return { id: typeof m.id === 'string' ? m.id : `hist-${idx}`, role, text, streaming: false }; } +let toolSeq = 0; +const nextToolId = (): string => `tool-${++toolSeq}`; + +/** Grow the trailing text block, or open a new one if the last block was a tool. */ +function growTextBlock(blocks: MessageBlock[], text: string): void { + if (!text) return; + const last = blocks[blocks.length - 1]; + if (last && last.kind === 'text') last.text += text; + else blocks.push({ kind: 'text', text }); +} + +/** + * Fold a `stream_chunk` node-state into the ordered block list, returning `true` + * when the chunk carried tool activity. + * + * Tool activity rides `state.rawResponse.toolCall` / `state.rawResponse.toolResult` + * — **NOT** `state.toolResult`. Reading the wrong path leaves every chip stuck on + * "running…" forever (the exact bug the daemon SPA hit and this mirror avoids). + */ +function applyToolChunk(blocks: MessageBlock[], state: unknown): boolean { + const raw = (state as { rawResponse?: unknown } | null | undefined)?.rawResponse; + if (!raw || typeof raw !== 'object') return false; + const call = (raw as { toolCall?: { name?: string; arguments?: unknown } }).toolCall; + const res = (raw as { toolResult?: { name?: string; isError?: boolean; result?: unknown } }).toolResult; + if (call) { + const args = typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {}); + blocks.push({ kind: 'tool', tool: { id: nextToolId(), name: call.name ?? '', args, done: false } }); + return true; + } + if (res) { + const result = typeof res.result === 'string' ? res.result : JSON.stringify(res.result ?? ''); + // Complete the most-recent still-open tool block matching this name. + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + if (b && b.kind === 'tool' && b.tool.name === (res.name ?? '') && !b.tool.done) { + b.tool.done = true; + b.tool.isError = !!res.isError; + b.tool.result = result; + break; + } + } + return true; + } + return false; +} + export class ConversationController { private readonly config: ChatWidgetConfig; private readonly events: ConversationEvents; @@ -674,7 +752,8 @@ export class ConversationController { this.messages.push({ id: this.nextId('u'), role: 'user', text: trimmed, streaming: false }); // 2. Placeholder assistant bubble we grow as tokens arrive. - const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true }; + const showTools = this.config.showToolActivity === true; + const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true, blocks: showTools ? [] : undefined }; this.messages.push(assistant); this.emitMessages(); @@ -687,6 +766,14 @@ export class ConversationController { const token = event.token ?? event.data?.token ?? ''; if (token) { assistant.text += token; + // Grow the trailing text block so prose interleaves with any + // tool chips in the order the model produced them. + if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token); + this.emitMessages(); + } + } else if (showTools && event.type === 'stream_chunk') { + // Tool activity (gated). Read state.rawResponse.toolCall/.toolResult. + if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) { this.emitMessages(); } } else { @@ -715,6 +802,11 @@ export class ConversationController { if (suggestions.length > 0) { assistant.suggestions = suggestions; } + // Only keep blocks for turns that actually invoked a tool — a prose-only + // turn drops back to the normal markdown text path (with the final text). + if (assistant.blocks && !assistant.blocks.some((b) => b.kind === 'tool')) { + assistant.blocks = undefined; + } assistant.streaming = false; this.emitMessages(); } catch (err) { diff --git a/src/element.ts b/src/element.ts index 5096af8..efbfdf7 100644 --- a/src/element.ts +++ b/src/element.ts @@ -17,7 +17,16 @@ import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js/min'; import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js'; import { needsUserInfo, resolveConfig } from './config.js'; -import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt, SUPPORTED_INTERACTION_CAPABILITIES } from './conversation.js'; +import { + type ChatMessage, + type Citation, + type ConnectionStatus, + ConversationController, + type IdentityRestore, + type Interrupt, + SUPPORTED_INTERACTION_CAPABILITIES, + type ToolCall, +} from './conversation.js'; import { SMOOTH_ICON_SVG } from './logo.js'; import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js'; import { buildStyles } from './styles.js'; @@ -52,7 +61,7 @@ function phoneToE164(value: string): string | null { /** Public smooth-operator repo — the "powered by" header tag + footer link here. */ const SMOOTH_OPERATOR_URL = 'https://github.com/SmooAI/smooth-operator'; -const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding'] as const; +const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding', 'show-tool-activity'] as const; /** * Inline SVG icons (static, trusted strings — never interpolated with user data). @@ -75,6 +84,8 @@ const ICON = { user: ``, /** Tool-confirmation interrupt — a shield. */ shield: ``, + /** Tool-activity chip — a wrench. */ + tool: ``, } as const; /** @@ -292,6 +303,10 @@ export class SmoothAgentChatElement extends HTMLElement { /** How many chars of {@link streamTarget} are currently shown. */ private displayedLength = 0; private rafId = 0; + /** Block structure signature of the last-rendered streaming message (tool chips + * only — text growth doesn't change it), so a chip add/resolve forces a rebuild + * while plain trailing-text growth stays on the fast reveal path. */ + private prevBlockSig = ''; constructor() { super(); @@ -376,6 +391,7 @@ export class SmoothAgentChatElement extends HTMLElement { collectConsent: this.overrides.collectConsent, allowChatRestore: this.overrides.allowChatRestore, allowAnonymous: this.overrides.allowAnonymous, + showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute('show-tool-activity'), theme, }; } @@ -1046,14 +1062,19 @@ export class SmoothAgentChatElement extends HTMLElement { // finalize transition (streaming → done) needs the markdown render + sources last.streaming !== prevLast.streaming || // first token after the typing indicator needs the bubble swapped in - (!!last.streaming && !prevLast.text && !!last.text); + (!!last.streaming && !prevLast.text && !!last.text) || + // a tool chip was added or resolved (text growth alone doesn't change this) + this.blockSig(last) !== this.prevBlockSig; this.messages = messages; - - if (!structural && last && last.streaming && last.id === this.streamMsgId) { - // Fast path: the streaming tail just grew. Bump the reveal target and - // let the rAF loop catch up — no DOM rebuild, no per-token reflow. - this.streamTarget = last.text; + this.prevBlockSig = this.blockSig(last); + + if (!structural && last && last.streaming && this.tailKey(last) === this.streamMsgId) { + // Fast path: the streaming (trailing) text just grew. Bump the reveal + // target and let the rAF loop catch up — no DOM rebuild, no per-token + // reflow. `tailText` is the live trailing text block for a tool turn, or + // the whole message for a plain-prose turn. + this.streamTarget = this.tailText(last); this.ensureRevealLoop(); return; } @@ -1061,6 +1082,32 @@ export class SmoothAgentChatElement extends HTMLElement { this.renderMessages(); } + /** True when an assistant message's turn invoked at least one tool. */ + private hasToolBlocks(m?: ChatMessage): boolean { + return !!m?.blocks?.some((b) => b.kind === 'tool'); + } + + /** Signature that changes only when a tool chip is added or resolved (text growth is 'x'). */ + private blockSig(m?: ChatMessage): string { + if (!m?.blocks) return ''; + return m.blocks.map((b) => (b.kind === 'tool' ? `t:${b.tool.id}:${b.tool.done ? 1 : 0}` : 'x')).join('|'); + } + + /** The live (last) text block for a tool turn, else the whole message text. */ + private tailText(m: ChatMessage): string { + if (this.hasToolBlocks(m) && m.blocks) { + const last = m.blocks[m.blocks.length - 1]; + return last?.kind === 'text' ? last.text : ''; + } + return m.text; + } + + /** Reveal-binding key — composite for a tool-turn tail (so a new trailing block rebinds), else msg id. */ + private tailKey(m: ChatMessage): string { + if (this.hasToolBlocks(m) && m.blocks) return `${m.id}#${m.blocks.length - 1}`; + return m.id; + } + private renderMessages(): void { if (!this.messagesEl) return; this.resetReveal(); @@ -1087,6 +1134,16 @@ export class SmoothAgentChatElement extends HTMLElement { } for (const msg of this.messages) { + // Tool-activity turns (gated by `showToolActivity`) render as an ordered + // strip of prose bubbles + inline tool chips instead of one bubble. + if (msg.role === 'assistant' && this.hasToolBlocks(msg)) { + this.messagesEl.appendChild(this.buildRow('assistant', this.renderAssistantBlocks(msg))); + if (!msg.streaming && msg.citations && msg.citations.length > 0) { + this.messagesEl.appendChild(this.renderSources(msg.citations)); + } + continue; + } + const bubble = document.createElement('div'); bubble.className = `bubble ${msg.role}`; if (msg.role === 'assistant' && msg.streaming && !msg.text) { @@ -1168,22 +1225,89 @@ export class SmoothAgentChatElement extends HTMLElement { * doesn't restart the reveal from zero), then resumes the loop. */ private bindReveal(msg: ChatMessage, bubble: HTMLElement): void { - const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0; + // `tailKey`/`tailText` collapse to the message id + full text for a plain + // prose turn, and to the live trailing text block for a tool turn — so both + // the single streaming bubble and a tool turn's trailing prose share this path. + const key = this.tailKey(msg); + const target = this.tailText(msg); + const carryOver = key === this.streamMsgId ? Math.min(this.displayedLength, target.length) : 0; this.streamBubbleEl = bubble; - this.streamMsgId = msg.id; - this.streamTarget = msg.text; + this.streamMsgId = key; + this.streamTarget = target; this.displayedLength = carryOver; if (this.prefersReducedMotion()) { // No animation: show everything immediately. - this.displayedLength = msg.text.length; - bubble.textContent = msg.text; + this.displayedLength = target.length; + bubble.textContent = target; return; } - bubble.textContent = msg.text.slice(0, this.displayedLength); + bubble.textContent = target.slice(0, this.displayedLength); this.ensureRevealLoop(); } + /** + * Render a tool-activity assistant turn as an ordered strip: prose bubbles and + * inline tool chips in the order the model produced them (mirrors the daemon + * SPA's `blocks`). The live trailing text block (while streaming) binds to the + * rAF reveal; earlier/finalized text blocks render as sanitized markdown. + */ + private renderAssistantBlocks(msg: ChatMessage): HTMLElement { + const wrap = document.createElement('div'); + wrap.className = 'blocks'; + const blocks = msg.blocks ?? []; + const lastIdx = blocks.length - 1; + blocks.forEach((block, i) => { + if (block.kind === 'tool') { + wrap.appendChild(this.buildToolChip(block.tool)); + return; + } + const bubble = document.createElement('div'); + bubble.className = 'bubble assistant'; + if (msg.streaming && i === lastIdx) { + // Live trailing prose → plain text + cursor, driven by the reveal loop. + bubble.classList.add('cursor'); + this.bindReveal(msg, bubble); + } else { + // Settled prose → sanitized markdown (same allowlisted renderer as bubbles). + bubble.classList.add('md'); + bubble.innerHTML = renderMarkdown(block.text); + } + wrap.appendChild(bubble); + }); + return wrap; + } + + /** + * A single tool-activity chip: icon + tool name + status (running… / done / error), + * with a truncated args preview. Tool name/args are set via `textContent` so a + * tool payload can never inject markup. + */ + private buildToolChip(tool: ToolCall): HTMLElement { + const chip = document.createElement('div'); + chip.className = `toolchip ${tool.done ? (tool.isError ? 'error' : 'done') : 'running'}`; + chip.setAttribute('part', 'tool-chip'); + + const icon = document.createElement('span'); + icon.className = 'ti'; + icon.innerHTML = ICON.tool; // static, trusted + const name = document.createElement('span'); + name.className = 'tn'; + name.textContent = tool.name || 'tool'; + const status = document.createElement('span'); + status.className = 'ts'; + status.textContent = tool.done ? (tool.isError ? 'error' : 'done') : 'running…'; + chip.append(icon, name, status); + + if (tool.args && tool.args !== '{}' && tool.args !== '""') { + const args = document.createElement('span'); + args.className = 'ta'; + args.textContent = tool.args.length > 80 ? `${tool.args.slice(0, 80)}…` : tool.args; + chip.appendChild(args); + } + return chip; + } + /** Start the rAF loop if it isn't already running. */ private ensureRevealLoop(): void { if (this.prefersReducedMotion() || typeof requestAnimationFrame !== 'function') { diff --git a/src/index.ts b/src/index.ts index 4cbe437..206c111 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,8 +39,10 @@ export { type ConnectionStatus, type ConversationEvents, type IdentityRestore, + type MessageBlock, type RestorableConversation, type Role, + type ToolCall, type UserInfo, } from './conversation.js'; export { diff --git a/src/styles.ts b/src/styles.ts index c2abfc0..e069e5f 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -29,6 +29,7 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove --sac-bg: ${theme.background}; --sac-primary: ${theme.primary}; --sac-primary-text: ${theme.primaryText}; + --sac-secondary: ${theme.secondary}; --sac-assistant-bubble: ${theme.assistantBubble}; --sac-assistant-bubble-text: ${theme.assistantBubbleText}; --sac-user-bubble: ${theme.userBubble}; @@ -349,6 +350,47 @@ ${ } @keyframes sac-blink { to { opacity: 0 } } +/* Interleaved tool-activity strip (gated by showToolActivity): prose bubbles + and inline tool chips stacked in the order the model produced them. */ +.blocks { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; } +.blocks .bubble { align-self: flex-start; } +.toolchip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 5px 10px; + border-radius: 99px; + font-size: 12px; + line-height: 1.3; + color: color-mix(in srgb, var(--sac-text) 78%, transparent); + background: color-mix(in srgb, var(--sac-text) 6%, transparent); + border: 1px solid color-mix(in srgb, var(--sac-text) 12%, transparent); + animation: sac-msg-in .3s var(--sac-ease) both; +} +.toolchip .ti { display: inline-flex; flex: none; opacity: .8; } +.toolchip .ti svg { width: 13px; height: 13px; } +.toolchip .tn { font-weight: 600; letter-spacing: .01em; } +.toolchip .ts { opacity: .7; } +.toolchip .ta { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + opacity: .6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.toolchip.running { border-color: color-mix(in srgb, var(--sac-primary) 45%, transparent); } +.toolchip.running .ts { color: var(--sac-primary); opacity: 1; animation: sac-typing 1.3s ease-in-out infinite; } +.toolchip.done .ts::before { content: '✓ '; } +.toolchip.error { + color: var(--sac-secondary); + border-color: color-mix(in srgb, var(--sac-secondary) 50%, transparent); + background: color-mix(in srgb, var(--sac-secondary) 10%, transparent); +} +.toolchip.error .ts::before { content: '! '; } + /* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */ /* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules keep them legible inside the tight Aurora-Glass bubble + citation card. */ diff --git a/src/tool-blocks.test.ts b/src/tool-blocks.test.ts new file mode 100644 index 0000000..c282223 --- /dev/null +++ b/src/tool-blocks.test.ts @@ -0,0 +1,94 @@ +/** + * Render tests for interleaved tool-activity blocks in element.ts. + * + * Drives the element's `handleMessages` hook with `ChatMessage.blocks` (as the + * ConversationController produces when `showToolActivity` is on) and asserts the + * DOM: prose bubbles + inline tool chips in order, chip status classes, and that + * a plain-prose message (no blocks) is untouched. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChatMessage, MessageBlock } from './conversation.js'; +import { defineChatWidget, ELEMENT_TAG } from './element.js'; + +interface Harness extends HTMLElement { + handleMessages(messages: ChatMessage[], greeting: string): void; +} + +function mount(): Harness { + // Reduced-motion so the reveal snaps (no rAF pumping needed for these assertions). + vi.stubGlobal('matchMedia', (q: string) => ({ matches: /reduced-motion/.test(q), media: q, addEventListener() {}, removeEventListener() {} })); + defineChatWidget(); + const el = document.createElement(ELEMENT_TAG) as Harness; + el.setAttribute('endpoint', 'wss://e/ws'); + el.setAttribute('agent-id', 'a1'); + el.setAttribute('greeting', ''); + document.body.appendChild(el); + return el; +} + +function assistantWithBlocks(id: string, blocks: MessageBlock[], streaming: boolean): ChatMessage { + const text = blocks.filter((b): b is Extract => b.kind === 'text').map((b) => b.text).join(''); + return { id, role: 'assistant', text, streaming, blocks }; +} + +describe('tool-activity block rendering', () => { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + }); + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('renders prose bubbles and tool chips interleaved in order', () => { + const el = mount(); + el.handleMessages( + [ + assistantWithBlocks( + 'm1', + [ + { kind: 'text', text: 'Let me check. ' }, + { kind: 'tool', tool: { id: 't1', name: 'search', args: '{"q":"x"}', done: true, isError: false, result: 'ok' } }, + { kind: 'text', text: 'Found it.' }, + ], + false, + ), + ], + '', + ); + const strip = el.shadowRoot!.querySelector('.blocks')!; + expect(strip).not.toBeNull(); + const kinds = [...strip.children].map((c) => (c.classList.contains('toolchip') ? 'chip' : 'bubble')); + expect(kinds).toEqual(['bubble', 'chip', 'bubble']); + expect(strip.querySelector('.toolchip .tn')?.textContent).toBe('search'); + expect(strip.querySelector('.toolchip')?.classList.contains('done')).toBe(true); + }); + + it('shows a running chip with a "running…" status while the tool is in flight', () => { + const el = mount(); + el.handleMessages( + [assistantWithBlocks('m1', [{ kind: 'tool', tool: { id: 't1', name: 'read_file', args: '{}', done: false } }], true)], + '', + ); + const chip = el.shadowRoot!.querySelector('.toolchip')!; + expect(chip.classList.contains('running')).toBe(true); + expect(chip.querySelector('.ts')?.textContent).toContain('running'); + }); + + it('marks an errored tool with the error class', () => { + const el = mount(); + el.handleMessages( + [assistantWithBlocks('m1', [{ kind: 'tool', tool: { id: 't1', name: 'bash', args: 'ls', done: true, isError: true, result: 'boom' } }], false)], + '', + ); + expect(el.shadowRoot!.querySelector('.toolchip')?.classList.contains('error')).toBe(true); + }); + + it('leaves a plain-prose message on the normal single-bubble path (no .blocks strip)', () => { + const el = mount(); + el.handleMessages([{ id: 'm1', role: 'assistant', text: 'just prose', streaming: false }], ''); + expect(el.shadowRoot!.querySelector('.blocks')).toBeNull(); + expect(el.shadowRoot!.querySelector('.bubble.assistant.md')?.textContent).toBe('just prose'); + }); +});