From 375e851d8d55a88a5d749bf78b59c587a26db915 Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Thu, 15 Oct 2026 14:02:51 -0700 Subject: [PATCH] feat(apollo-react): add the Wind chat tool-call and tree renderers Render agent tool call messages in the Wind chat on the apollo-wind ToolCall primitive (status line, input, traces, escalation task links, errors and output per display mode) and tree messages as a disclosure tree, both registered as Wind built-ins behind injected renderers. Hoist the renderer-agnostic reading of tool calls and trees into src/chat/headless and switch Material to it: status from input, output and the span tree, tool name and duration, task URLs, section values, the span icon lookup and its SVG set, and the tree row model. Task links are only rendered for http(s) or relative URLs in both renderers. MST-16565 Co-Authored-By: Claude Opus 5.5 --- packages/apollo-react/src/chat/README.md | 2 +- .../src/chat/headless/markdown/citations.ts | 19 +- .../chat/headless/tool-call/span-icon.test.ts | 27 ++ .../headless/tool-call/span-icon.ts} | 26 +- .../headless/tool-call/span-icons.ts} | 0 .../tool-call/tool-call-model.test.ts | 113 ++++++++ .../headless/tool-call/tool-call-model.ts | 172 +++++++++++++ .../src/chat/headless/tree/chat-tree.test.ts | 71 +++++ .../src/chat/headless/tree/chat-tree.ts | 87 +++++++ .../chat/headless/utils/openable-url.test.ts | 25 ++ .../src/chat/headless/utils/openable-url.ts | 15 ++ .../apollo-react/src/chat/locales/en.json | 25 +- .../src/chat/stories/chat-story-fixtures.ts | 86 +++++++ .../src/chat/stories/chat-story-scenarios.ts | 51 +++- .../chat/wind/renderers/disclosure-tree.tsx | 138 ++++++++++ .../src/chat/wind/renderers/index.ts | 10 +- .../renderers/tool-call-renderer.test.tsx | 243 ++++++++++++++++++ .../wind/renderers/tool-call-renderer.tsx | 226 ++++++++++++++++ .../chat/wind/renderers/tool-call-strings.ts | 97 +++++++ .../wind/renderers/tree-renderer.test.tsx | 96 +++++++ .../src/chat/wind/renderers/tree-renderer.tsx | 69 +++++ .../components/message/tree/tree-renderer.tsx | 77 ++---- .../components/ap-tool-call/ApToolCall.tsx | 189 +++----------- .../components/ToolCallSection.test.tsx | 29 +++ .../components/ToolCallSection.tsx | 20 +- 25 files changed, 1638 insertions(+), 275 deletions(-) create mode 100644 packages/apollo-react/src/chat/headless/tool-call/span-icon.test.ts rename packages/apollo-react/src/{material/components/ap-tool-call/utils/traceIconUtils.ts => chat/headless/tool-call/span-icon.ts} (81%) rename packages/apollo-react/src/{material/components/ap-tool-call/assets/spanIcons.ts => chat/headless/tool-call/span-icons.ts} (100%) create mode 100644 packages/apollo-react/src/chat/headless/tool-call/tool-call-model.test.ts create mode 100644 packages/apollo-react/src/chat/headless/tool-call/tool-call-model.ts create mode 100644 packages/apollo-react/src/chat/headless/tree/chat-tree.test.ts create mode 100644 packages/apollo-react/src/chat/headless/tree/chat-tree.ts create mode 100644 packages/apollo-react/src/chat/headless/utils/openable-url.test.ts create mode 100644 packages/apollo-react/src/chat/headless/utils/openable-url.ts create mode 100644 packages/apollo-react/src/chat/wind/renderers/disclosure-tree.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/tool-call-renderer.test.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/tool-call-renderer.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/tool-call-strings.ts create mode 100644 packages/apollo-react/src/chat/wind/renderers/tree-renderer.test.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/tree-renderer.tsx create mode 100644 packages/apollo-react/src/material/components/ap-tool-call/components/ToolCallSection.test.tsx diff --git a/packages/apollo-react/src/chat/README.md b/packages/apollo-react/src/chat/README.md index 6a47cb39b3..802b5f3ed0 100644 --- a/packages/apollo-react/src/chat/README.md +++ b/packages/apollo-react/src/chat/README.md @@ -33,7 +33,7 @@ The Material renderer depends on this folder, never the other way round. - The root element carries the chat theme as a class (`light`, `dark`, `light-hc`, `dark-hc`, `future-light`, `future-dark`), so it themes itself regardless of the host's body class. Menus, tooltips and hover cards portal inside that root, or into a themed node inside `portalContainer` when the host passes one. Modal dialogs (code block expand and HTML preview) always portal to that themed node, placed in `portalContainer` or else `document.body` at z-index 1300 (Material's modal level), so they cover the page rather than the chat panel. - `spacing.compactMode` sets `data-density="compact"` on the root. -- `icon` values on custom header actions and message actions are renderer-specific: Material takes Material Icons names, Wind takes lucide names (kebab-case such as `file-text`, or the PascalCase export name `FileText`). Wind loads each icon lazily through `lucide-react/dynamic` and shows a neutral box for names it cannot resolve. +- `icon` values on custom header actions, message actions and tree message spans (`span.data.icon`) are renderer-specific: Material takes Material Icons names, Wind takes lucide names (kebab-case such as `file-text`, or the PascalCase export name `FileText`). Wind loads each icon lazily through `lucide-react/dynamic` and shows a neutral box for names it cannot resolve. ## Styles diff --git a/packages/apollo-react/src/chat/headless/markdown/citations.ts b/packages/apollo-react/src/chat/headless/markdown/citations.ts index 8c12dc6831..8d9f54c313 100644 --- a/packages/apollo-react/src/chat/headless/markdown/citations.ts +++ b/packages/apollo-react/src/chat/headless/markdown/citations.ts @@ -1,4 +1,5 @@ import type { AutopilotChatMessage, PdfCitation, UrlCitation } from '../../service'; +import { isOpenableUrl } from '../utils/openable-url'; /** The props `citationPlugin` gives each `citation` node. */ export interface CitationMarker { @@ -23,22 +24,6 @@ export function citationFromMarker({ : { id, title, download_url: download_url as string, page_number: page_number as number }; } -const OPENABLE_PROTOCOLS = new Set(['http:', 'https:']); - -/** - * Whether a citation URL may be opened. Citations come from the model or the host's backend, so - * a `javascript:`, `data:`, `blob:` or `vbscript:` URL must not reach `window.open`. The parser - * lowercases the scheme and drops the whitespace and control characters that defeat a substring - * check; relative URLs resolve against the page. - */ -export function isOpenableCitationUrl(href: string): boolean { - try { - return OPENABLE_PROTOCOLS.has(new URL(href, window.location.href).protocol); - } catch { - return false; - } -} - /** * Where a citation opens: its URL, or its PDF at the cited page. `undefined` when there is none * or it is not an http(s) or relative URL. @@ -50,7 +35,7 @@ export function getCitationUrl(citation: UrlCitation | PdfCitation): string | un } else if ('download_url' in citation && citation.download_url) { href = `${citation.download_url}${citation.page_number ? `#page=${citation.page_number}` : ''}`; } - return href && isOpenableCitationUrl(href) ? href : undefined; + return href && isOpenableUrl(href) ? href : undefined; } /** Every citation in a group's content parts, first occurrence of each id only. */ diff --git a/packages/apollo-react/src/chat/headless/tool-call/span-icon.test.ts b/packages/apollo-react/src/chat/headless/tool-call/span-icon.test.ts new file mode 100644 index 0000000000..63cd18c269 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/tool-call/span-icon.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { getSpanIconSvg } from './span-icon'; +import { spanIconsMap } from './span-icons'; + +describe('getSpanIconSvg', () => { + it('maps span types, attribute-driven types and functions to icons', () => { + expect(getSpanIconSvg({ type: 'agentRun' })).toBe(spanIconsMap.get('agent-run')); + expect(getSpanIconSvg({ type: 'toolCall', attributes: { toolType: 'Agent' } })).toBe( + spanIconsMap.get('agent-run') + ); + expect(getSpanIconSvg({ type: 'completion', attributes: { model: 'gpt-4o' } })).toBe( + spanIconsMap.get('open-ai') + ); + expect(getSpanIconSvg({ type: 'function_call_sync' })).toBe(spanIconsMap.get('function-span')); + expect(getSpanIconSvg({ type: 'something-new' })).toBe(spanIconsMap.get('default-trace-item')); + }); + + it('copes with odd attributes', () => { + expect(getSpanIconSvg({ type: 'completion', attributes: { model: 42 } })).toBe( + spanIconsMap.get('generic-ai') + ); + expect(getSpanIconSvg({ type: 'completion', attributes: '{bad' })).toBe( + spanIconsMap.get('generic-ai') + ); + }); +}); diff --git a/packages/apollo-react/src/material/components/ap-tool-call/utils/traceIconUtils.ts b/packages/apollo-react/src/chat/headless/tool-call/span-icon.ts similarity index 81% rename from packages/apollo-react/src/material/components/ap-tool-call/utils/traceIconUtils.ts rename to packages/apollo-react/src/chat/headless/tool-call/span-icon.ts index 885dcaf18b..c1018a4a80 100644 --- a/packages/apollo-react/src/material/components/ap-tool-call/utils/traceIconUtils.ts +++ b/packages/apollo-react/src/chat/headless/tool-call/span-icon.ts @@ -1,5 +1,5 @@ -import type { TSpan } from '../ApToolCall.types'; -import { spanIconsMap } from '../assets/spanIcons'; +import { spanIconsMap } from './span-icons'; +import { getSpanAttributes, type ToolCallSpanData } from './tool-call-model'; const SPAN_FUNCTION_TYPES = [ 'function_call_sync', @@ -58,23 +58,11 @@ const API_WORKFLOW_TASK_ICON_MAP = new Map([ ['WorkflowStart', 'workflow-start-icon'], ]); -function getAttributes(span: TSpan): Record { - const attrs = span.attributes; - if (typeof attrs === 'string') { - try { - return JSON.parse(attrs); - } catch { - return {}; - } - } - return (attrs as Record) ?? {}; -} - function isFunctionSpan(type: string): boolean { return (SPAN_FUNCTION_TYPES as readonly string[]).includes(type); } -function getIconKey(span: TSpan): string { +function getIconKey(span: ToolCallSpanData): string { const spanType = span.type ?? ''; const simpleKey = SPAN_ICON_MAP.get(spanType); @@ -82,12 +70,14 @@ function getIconKey(span: TSpan): string { return simpleKey; } - const attrs = getAttributes(span); + const attrs = getSpanAttributes(span); switch (spanType) { case 'completion': case 'agentOutput': - return (attrs.model as string | undefined)?.includes('gpt-') ? 'open-ai' : 'generic-ai'; + return typeof attrs.model === 'string' && attrs.model.includes('gpt-') + ? 'open-ai' + : 'generic-ai'; case 'toolCall': return attrs.toolType === 'Agent' ? 'agent-run' : 'process-tool'; @@ -100,6 +90,6 @@ function getIconKey(span: TSpan): string { } } -export function getSpanIconSvg(span: TSpan): string | null { +export function getSpanIconSvg(span: ToolCallSpanData): string | null { return spanIconsMap.get(getIconKey(span)) ?? null; } diff --git a/packages/apollo-react/src/material/components/ap-tool-call/assets/spanIcons.ts b/packages/apollo-react/src/chat/headless/tool-call/span-icons.ts similarity index 100% rename from packages/apollo-react/src/material/components/ap-tool-call/assets/spanIcons.ts rename to packages/apollo-react/src/chat/headless/tool-call/span-icons.ts diff --git a/packages/apollo-react/src/chat/headless/tool-call/tool-call-model.test.ts b/packages/apollo-react/src/chat/headless/tool-call/tool-call-model.test.ts new file mode 100644 index 0000000000..1f230261fc --- /dev/null +++ b/packages/apollo-react/src/chat/headless/tool-call/tool-call-model.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { + getSpanAttributes, + getTaskUrls, + getToolCallDurationSeconds, + getToolCallName, + getToolCallState, + getToolCallValues, + getVisibleTraceChildren, + type ToolCallSpanNode, +} from './tool-call-model'; + +const node = ( + id: string, + data: Partial = {}, + children?: ToolCallSpanNode[] +): ToolCallSpanNode => ({ key: id, name: id, data: { id, ...data }, children }); + +describe('tool call model', () => { + it('reads the state from input and output first', () => { + expect(getToolCallState({ input: { q: 1 } })).toBe('loading'); + expect(getToolCallState({ input: { q: 1 }, output: 'ok' })).toBe('success'); + expect(getToolCallState({ input: { q: 1 }, isError: true })).toBe('error'); + expect(getToolCallState({ input: { q: 1 }, isError: false })).toBe('success'); + }); + + it('reads the state from the span tree otherwise', () => { + expect(getToolCallState({ span: node('a', { status: 'unset' }) })).toBe('loading'); + expect(getToolCallState({ span: node('a', { status: 'ok' }) })).toBe('success'); + expect( + getToolCallState({ + span: node('a', { status: 'ok' }, [node('b', {}, [node('c', { status: 'error' })])]), + }) + ).toBe('error'); + expect(getToolCallState({ toolName: 'x' })).toBeNull(); + }); + + it('names the tool and times the call as Material does', () => { + expect(getToolCallName({ toolName: 'get_weather_now' })).toBe('get weather now'); + expect(getToolCallName({ span: node('a', {}, []) })).toBe('a'); + expect(getToolCallName({ span: { ...node('a'), name: 'Tool call - Web_Search' } })).toBe( + 'Web Search' + ); + expect(getToolCallName({})).toBe('tool'); + expect( + getToolCallDurationSeconds({ + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T00:00:02.500Z', + }) + ).toBe(2.5); + expect(getToolCallDurationSeconds({})).toBe(0); + }); + + it('collects task links from the span and its descendants, string attributes included', () => { + const span = node('root', { attributes: { taskUrl: 'https://a.test/1' }, status: 'ok' }, [ + node('child', { + attributes: JSON.stringify({ taskUrl: 'https://a.test/2' }), + status: 'unset', + }), + ]); + + expect(getTaskUrls(span)).toEqual([ + { taskUrl: 'https://a.test/1', status: 'ok' }, + { taskUrl: 'https://a.test/2', status: 'unset' }, + ]); + expect(getTaskUrls(undefined)).toEqual([]); + }); + + it('takes section values from the message, then from the span attributes', () => { + expect(getToolCallValues({ input: { q: 1 }, output: 'done', isError: false })).toEqual({ + input: { q: 1 }, + error: undefined, + output: 'done', + }); + expect(getToolCallValues({ input: { q: 1 }, output: 'boom', isError: true })).toEqual({ + input: { q: 1 }, + error: 'boom', + output: undefined, + }); + expect( + getToolCallValues({ + span: node('a', { attributes: { arguments: { q: 2 }, result: { r: 1 }, error: 'e' } }), + }) + ).toEqual({ input: { q: 2 }, error: 'e', output: { r: 1 } }); + }); + + it('leaves model calls and agent output out of the traces', () => { + const span = node('root', {}, [ + node('tool', { type: 'agentTool' }), + node('llm', { type: 'llmCall' }), + node('out', { type: 'agentOutput' }), + ]); + + expect(getVisibleTraceChildren(span).map((child) => child.key)).toEqual(['tool']); + }); + + it('treats malformed string attributes as none, and ignores non-string task links', () => { + expect(getSpanAttributes({ attributes: '{bad' })).toEqual({}); + expect(getTaskUrls(node('a', { attributes: '{bad' }))).toEqual([]); + expect(getTaskUrls(node('a', { attributes: { taskUrl: 42 } }))).toEqual([]); + }); + + it('lets input and output decide over a span that failed', () => { + expect( + getToolCallState({ input: { q: 1 }, output: 'ok', span: node('a', { status: 'error' }) }) + ).toBe('success'); + }); + + it('has no state for isError without input or span; the renderer decides', () => { + expect(getToolCallState({ toolName: 'x', isError: true })).toBeNull(); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/tool-call/tool-call-model.ts b/packages/apollo-react/src/chat/headless/tool-call/tool-call-model.ts new file mode 100644 index 0000000000..6f8c4c7249 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/tool-call/tool-call-model.ts @@ -0,0 +1,172 @@ +/** + * Renderer-agnostic reading of an agent tool call message (`meta` of an + * `AGENTS_TOOL_CALL_RENDERER` message), shared by the Material `ApToolCall` and the Wind renderer. + */ + +/** One span of an agent trace. */ +export interface ToolCallSpanData { + id?: string; + name?: string; + type?: string; + status?: 'unset' | 'ok' | 'error'; + startTime?: string; + endTime?: string; + attributes?: Record | string; +} + +export interface ToolCallSpanNode { + key: string; + name: string; + data: T; + children?: ToolCallSpanNode[]; +} + +export type ToolCallDisplayMode = 'FullTrace' | 'InputsAndOutputs' | 'ToolNameOnly'; + +/** The tool call fields a message carries in `meta`. */ +export interface ToolCallInfo { + span?: ToolCallSpanNode; + toolName?: string; + input?: Record; + output?: Record | string; + isError?: boolean; + startTime?: string; + endTime?: string; + displayMode?: ToolCallDisplayMode; +} + +/** `null` when nothing says how the call went; Material then shows no status icon. */ +export type ToolCallState = 'loading' | 'error' | 'success' | null; + +export interface ToolCallTaskUrl { + taskUrl: string; + status: string | null; +} + +/** A span's attributes, parsed when they arrive as a JSON string. */ +export function getSpanAttributes(data: ToolCallSpanData): Record { + let attrs = data.attributes; + if (typeof attrs === 'string') { + try { + attrs = JSON.parse(attrs); + } catch { + return {}; + } + } + return (attrs || {}) as Record; +} + +/** Children shown as trace items: model calls and agent output are left out. */ +export function getVisibleTraceChildren(node: ToolCallSpanNode): ToolCallSpanNode[] { + return (node.children ?? []).filter( + (child) => child.data.type !== 'llmCall' && child.data.type !== 'agentOutput' + ); +} + +// Depth-first over every descendant, each span id once. +function forEachDescendant(span: ToolCallSpanNode, visit: (child: ToolCallSpanNode) => boolean) { + const visited = new Set(); + const stack = [...(span.children || [])]; + + while (stack.length > 0) { + const child = stack.pop(); + if (!child?.data?.id || visited.has(child.data.id)) { + continue; + } + visited.add(child.data.id); + if (visit(child)) { + return; + } + if (child.children?.length) { + stack.push(...child.children); + } + } +} + +/** Escalation task links on the span and its descendants, with each span's status. */ +export function getTaskUrls(span: ToolCallSpanNode | undefined): ToolCallTaskUrl[] { + if (!span) { + return []; + } + + const urls: ToolCallTaskUrl[] = []; + const collect = (node: ToolCallSpanNode) => { + const taskUrl = getSpanAttributes(node.data).taskUrl; + if (typeof taskUrl === 'string' && taskUrl) { + urls.push({ taskUrl, status: node.data.status || null }); + } + }; + + collect(span); + forEachDescendant(span, (child) => { + collect(child); + return false; + }); + return urls; +} + +export function hasChildSpanWithError(span: ToolCallSpanNode | undefined): boolean { + if (!span) { + return false; + } + + let found = false; + forEachDescendant(span, (child) => { + found = child.data.status === 'error'; + return found; + }); + return found; +} + +/** How the call went: from input and output when given, else from the span tree. */ +export function getToolCallState({ input, output, isError, span }: ToolCallInfo): ToolCallState { + if (input && !output && isError === undefined) { + return 'loading'; + } + if (input && (output || isError !== undefined)) { + return isError ? 'error' : 'success'; + } + if (span) { + if (span.data.status === 'unset') { + return 'loading'; + } + if (span.data.status === 'error' || hasChildSpanWithError(span)) { + return 'error'; + } + if (span.data.status === 'ok') { + return 'success'; + } + } + return null; +} + +/** The tool's name as people read it: underscores as spaces, without the span's prefix. */ +export function getToolCallName({ toolName, span }: ToolCallInfo): string { + return ( + toolName?.replace(/_/g, ' ') || + span?.name?.replace('Tool call - ', '').replace(/_/g, ' ') || + 'tool' + ); +} + +/** Elapsed seconds, from the message times or else the span's; 0 when either is missing. */ +export function getToolCallDurationSeconds({ startTime, endTime, span }: ToolCallInfo): number { + const end = endTime || span?.data?.endTime; + const start = startTime || span?.data?.startTime; + return end && start ? (new Date(end).getTime() - new Date(start).getTime()) / 1000 : 0; +} + +/** The section values, as Material picks them: message fields first, then span attributes. */ +export function getToolCallValues({ input, output, isError, span }: ToolCallInfo) { + const attributes = span ? getSpanAttributes(span.data) : {}; + return { + input: input || attributes.arguments || undefined, + error: (isError && output) || attributes.error || undefined, + // Shown for a successful output or a result attribute, but Material then shows `output` first. + output: (!isError && output) || attributes.result ? output || attributes.result : undefined, + } as { + input?: Record | string; + error?: Record | string; + output?: Record | string; + }; +} diff --git a/packages/apollo-react/src/chat/headless/tree/chat-tree.test.ts b/packages/apollo-react/src/chat/headless/tree/chat-tree.test.ts new file mode 100644 index 0000000000..5c54256dbd --- /dev/null +++ b/packages/apollo-react/src/chat/headless/tree/chat-tree.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { getExpandedTreeIds, toChatTreeItem } from './chat-tree'; + +const strings = { unknown: 'Unknown' }; + +describe('chat tree', () => { + it('builds rows as Material does, without a placeholder description', () => { + const item = toChatTreeItem( + { + key: 'r', + name: 'Root', + data: { id: 'root', attributes: { type: 'agentRun' }, additionalInfo: '2s', icon: 'bot' }, + children: [ + { key: 'c', name: 'Child', data: { attributes: { description: 'Looks things up' } } }, + { key: 'q', name: 'Quiet child', data: {} }, + { key: 'n', name: 'No data', data: undefined as never }, + ], + }, + 'root', + strings + ); + + expect(item).toEqual({ + id: 'root', + title: 'Root', + description: 'agentRun', + icon: 'bot', + expanded: undefined, + titleColor: undefined, + customIcon: undefined, + additionalInfo: '2s', + children: [ + { + id: 'root-0', + title: 'Child', + description: 'Looks things up', + icon: undefined, + expanded: undefined, + titleColor: undefined, + customIcon: undefined, + additionalInfo: undefined, + children: undefined, + }, + { + id: 'root-1', + title: 'Quiet child', + description: undefined, + icon: undefined, + expanded: undefined, + titleColor: undefined, + customIcon: undefined, + additionalInfo: undefined, + children: undefined, + }, + ], + }); + }); + + it('opens every row unless its span says expanded: false', () => { + expect( + getExpandedTreeIds([ + { + id: 'a', + title: 'A', + children: [{ id: 'b', title: 'B', expanded: false }], + }, + ]) + ).toEqual(['a']); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/tree/chat-tree.ts b/packages/apollo-react/src/chat/headless/tree/chat-tree.ts new file mode 100644 index 0000000000..19e00b36d5 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/tree/chat-tree.ts @@ -0,0 +1,87 @@ +import type { ToolCallSpanData, ToolCallSpanNode } from '../tool-call/tool-call-model'; + +/** The span fields a tree message can carry for display. */ +export type ChatTreeSpanData = ToolCallSpanData & { + additionalInfo?: string; + icon?: string; + titleColor?: string; + customIcon?: string; + expanded?: boolean; +}; + +export type ChatTreeSpanNode = ToolCallSpanNode; + +/** One row of a tree message, renderer-agnostic. */ +export interface ChatTreeItem { + id: string; + title: string; + /** Absent when the span has none, so no tooltip shows. */ + description?: string; + icon?: string; + expanded?: boolean; + titleColor?: string; + customIcon?: string; + additionalInfo?: string; + children?: ChatTreeItem[]; +} + +export interface ChatTreeStrings { + unknown: string; +} + +/** + * A tree message's span as rows, as Material's tree renderer builds them: the span id (or the + * position key) as id, its name as title, the description or type attribute as description + * (none otherwise: a placeholder tooltip says nothing). + * `transformChild` maps the children, so a host override applies at every level. + */ +export function toChatTreeItem( + node: ChatTreeSpanNode, + key: string, + strings: ChatTreeStrings, + transformChild: (child: ChatTreeSpanNode, key: string) => ChatTreeItem | null = ( + child, + childKey + ) => toChatTreeItem(child, childKey, strings) +): ChatTreeItem | null { + const data = node?.data; + if (!data) { + return null; + } + + const attributes = typeof data.attributes === 'object' ? data.attributes : undefined; + const children = + Array.isArray(node.children) && node.children.length > 0 + ? node.children + .map((child, index) => transformChild(child, `${key}-${index}`)) + .filter((item): item is ChatTreeItem => item !== null) + : undefined; + + return { + id: data.id ?? key, + title: node.name ?? strings.unknown, + description: + (attributes?.description as string | undefined) ?? + (attributes?.type as string | undefined) ?? + undefined, + icon: data.icon, + expanded: data.expanded, + titleColor: data.titleColor, + customIcon: data.customIcon, + additionalInfo: data.additionalInfo || undefined, + children, + }; +} + +/** Rows open unless their span says `expanded: false`, as in Material. */ +export function getExpandedTreeIds(items: readonly ChatTreeItem[]): string[] { + const ids: string[] = []; + const visit = (item: ChatTreeItem) => { + if (item.expanded !== false) { + ids.push(item.id); + } + item.children?.forEach(visit); + }; + items.forEach(visit); + return ids; +} diff --git a/packages/apollo-react/src/chat/headless/utils/openable-url.test.ts b/packages/apollo-react/src/chat/headless/utils/openable-url.test.ts new file mode 100644 index 0000000000..ef08f6b374 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/utils/openable-url.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { isOpenableUrl } from './openable-url'; + +describe('isOpenableUrl', () => { + it('allows http, https and relative URLs', () => { + for (const url of ['https://a.test', 'http://a.test/x', '/tasks/1', 'tasks/1', '//a.test']) { + expect(isOpenableUrl(url)).toBe(true); + } + }); + + it('refuses every other scheme and unparsable URLs', () => { + for (const url of [ + 'javascript:alert(1)', + ' JavaScript:alert(1)', + 'data:text/html,x', + 'blob:https://a.test/1', + 'vbscript:x', + 'file:///etc/passwd', + 'https:javascript:alert(1)', + ]) { + expect(isOpenableUrl(url)).toBe(false); + } + }); +}); diff --git a/packages/apollo-react/src/chat/headless/utils/openable-url.ts b/packages/apollo-react/src/chat/headless/utils/openable-url.ts new file mode 100644 index 0000000000..a785662b1d --- /dev/null +++ b/packages/apollo-react/src/chat/headless/utils/openable-url.ts @@ -0,0 +1,15 @@ +const OPENABLE_PROTOCOLS = new Set(['http:', 'https:']); + +/** + * Whether a URL from the model or a host backend may be opened or linked. A `javascript:`, + * `data:`, `blob:` or `vbscript:` URL must never reach `window.open` or an `href`. The parser + * lowercases the scheme and drops the whitespace and control characters that defeat a substring + * check; relative URLs resolve against the page. + */ +export function isOpenableUrl(href: string): boolean { + try { + return OPENABLE_PROTOCOLS.has(new URL(href, window.location.href).protocol); + } catch { + return false; + } +} diff --git a/packages/apollo-react/src/chat/locales/en.json b/packages/apollo-react/src/chat/locales/en.json index 600c13a4ec..0564841854 100644 --- a/packages/apollo-react/src/chat/locales/en.json +++ b/packages/apollo-react/src/chat/locales/en.json @@ -91,5 +91,28 @@ "autopilot-chat.message.dialog-close": "Close", "autopilot-chat.message.footnote-back": "Back to content", "autopilot-chat.message.task-done": "Done", - "autopilot-chat.message.task-open": "Not done" + "autopilot-chat.message.task-open": "Not done", + "autopilot-chat.tool-call.pending": "Pending", + "autopilot-chat.tool-call.executing": "Running", + "autopilot-chat.tool-call.completed": "Completed", + "autopilot-chat.tool-call.failed": "Failed", + "autopilot-chat.tool-call.running": "Running ''{name}''", + "autopilot-chat.tool-call.ran": "Ran ''{name}'' for {duration}", + "autopilot-chat.tool-call.seconds": "{seconds} seconds", + "autopilot-chat.tool-call.input": "Input", + "autopilot-chat.tool-call.output": "Output", + "autopilot-chat.tool-call.errors": "Errors", + "autopilot-chat.tool-call.traces": "Traces", + "autopilot-chat.tool-call.escalation": "Escalation", + "autopilot-chat.tool-call.task-waiting": "Waiting", + "autopilot-chat.tool-call.json.array-item": "item", + "autopilot-chat.tool-call.json.expand": "Expand {key}", + "autopilot-chat.tool-call.json.collapse": "Collapse {key}", + "autopilot-chat.tool-call.json.item-count": "{count, plural, one {# item} other {# items}}", + "autopilot-chat.tool-call.json.key-count": "{count, plural, one {# key} other {# keys}}", + "autopilot-chat.tool-call.json.path-copied": "Path copied", + "autopilot-chat.tool-call.json.copy-path-hint": "Click to copy this path", + "autopilot-chat.tool-call.json.copy-path-for": "Copy path for {path}", + "autopilot-chat.tool-call.json.copy-value": "Copy value", + "autopilot-chat.tool-call.json.copy-value-of": "Copy value of {key}" } diff --git a/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts b/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts index 9263dbf147..bdb576bca0 100644 --- a/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts +++ b/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts @@ -768,6 +768,14 @@ export function storyToolCallMeta(displayMode: StoryToolCallDisplayMode, now = D return { displayMode, span: storyTraceSpan(now) }; } + return storyInputsAndOutputsMeta(displayMode, now); +} + +/** A finished web search call with input and output. */ +export function storyInputsAndOutputsMeta( + displayMode: Exclude = 'InputsAndOutputs', + now = Date.now() +) { return { displayMode, input: { @@ -791,6 +799,84 @@ export function storyToolCallMeta(displayMode: StoryToolCallDisplayMode, now = D }; } +export interface StoryTreeNode { + data: Record; + children?: StoryTreeNode[]; +} + +/** + * The trace for the tree message: some spans describe themselves (shown as a tooltip) and some + * do not (no tooltip), and the top rows carry their duration. + */ +export function storyTreeSpan(now = Date.now()) { + const span = structuredClone(storyTraceSpan(now)) as unknown as StoryTreeNode; + const search = span.children?.[0]; + const run = search?.children?.[0]; + const firstSearch = run?.children?.[0]; + + Object.assign(span.data, { + attributes: { + ...(span.data.attributes as object), + description: 'Runs several web searches and merges the results.', + }, + additionalInfo: '25.0s', + }); + if (search) { + search.data.attributes = { + description: 'Plans the searches and summarises what they found.', + }; + } + if (run) { + run.data.additionalInfo = '23.0s'; + } + if (firstSearch) { + firstSearch.data.attributes = { description: 'Searched for "trending topics this week".' }; + firstSearch.data.additionalInfo = '4.0s'; + } + return span; +} + +/** A call that fails, for the error section and status. */ +export function storyFailedToolCallMeta(now = Date.now()) { + return { + displayMode: 'InputsAndOutputs' as const, + toolName: 'Update_Invoice', + input: { invoiceId: 'INV-4023', status: 'approved' }, + output: { message: 'Invoice INV-4023 is locked by another process.' }, + isError: true, + startTime: new Date(now - 1200).toISOString(), + endTime: new Date(now).toISOString(), + }; +} + +/** A trace waiting on a person: escalation task links, one still open. */ +export function storyEscalationSpan(now = Date.now()) { + const span = storyTraceSpan(now); + return { + ...span, + data: { + ...span.data, + status: 'unset', + attributes: { ...span.data.attributes, taskUrl: 'https://cloud.uipath.com/tasks/1001' }, + }, + children: [ + ...span.children, + { + key: 'escalation', + name: 'Escalation - Approve invoice', + data: { + id: 'escalation-1', + name: 'Escalation - Approve invoice', + startTime: new Date(now - 1000).toISOString(), + status: 'unset', + type: 'escalationTool', + attributes: { taskUrl: 'https://cloud.uipath.com/tasks/1002' }, + }, + }, + ], + }; +} + /** Older pages for the paginated history scenario, newest last. */ export function storyHistoryPage(page: number, pageSize = 6): AutopilotChatMessage[] { return Array.from({ length: pageSize }, (_, i) => { diff --git a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts index b9a93d4e3d..a713ac338e 100644 --- a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts +++ b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts @@ -34,9 +34,12 @@ import { STREAMED_MARKDOWN, STREAMING_REPLY, type StoryToolCallDisplayMode, + storyEscalationSpan, + storyFailedToolCallMeta, storyHistoryPage, + storyInputsAndOutputsMeta, storyToolCallMeta, - storyTraceSpan, + storyTreeSpan, WIND_RESPONSE_ACTIONS, withResourceIcons, } from './chat-story-fixtures'; @@ -263,25 +266,47 @@ export const CHAT_STORY_SCENARIOS = { 'tool-call': { label: 'Tool calls', description: - 'Appends one tool call per display mode: tool name only, inputs and outputs, full trace.', + 'Appends one tool call per display mode (tool name only, inputs and outputs, full trace), then a call that runs and finishes under the same id (an expanded body stays open), a failed call, and a trace waiting on escalation task links, which opens by itself.', run: (context) => { + const { service, delayMs } = context; + const toolCall = (meta: object, id?: string) => + respond(service, { + ...(id ? { id } : {}), + content: 'Tool call response', + widget: AGENTS_TOOL_CALL_RENDERER, + meta, + }); context.sendScriptedRequest({ content: 'Search the web for me.' }); TOOL_CALL_MODES.forEach((displayMode, index) => { - context.schedule( - () => - respond(context.service, { - content: 'Tool call response', - widget: AGENTS_TOOL_CALL_RENDERER, - meta: storyToolCallMeta(displayMode), - }), - context.delayMs * (index + 1) - ); + context.schedule(() => toolCall(storyToolCallMeta(displayMode)), delayMs * (index + 1)); }); + + // Starts when it is sent and finishes 3s later, so the line reads about 3 seconds. + const runningId = `tool-call-${crypto.randomUUID()}`; + let runningMeta: ReturnType | undefined; + context.schedule(() => { + runningMeta = { + ...storyInputsAndOutputsMeta(), + startTime: new Date().toISOString(), + }; + const { output: _output, isError: _isError, endTime: _endTime, ...running } = runningMeta; + toolCall(running, runningId); + }, delayMs * 4); + context.schedule( + () => toolCall({ ...runningMeta, endTime: new Date().toISOString() }, runningId), + delayMs * 4 + 3000 + ); + context.schedule(() => toolCall(storyFailedToolCallMeta()), delayMs * 5); + context.schedule( + () => toolCall({ displayMode: 'FullTrace', span: storyEscalationSpan() }), + delayMs * 6 + ); }, }, tree: { label: 'Tree message', - description: 'Appends a trace rendered as a tree.', + description: + 'Appends a trace rendered as a tree. Spans with a description show it as a tooltip; spans without one show none.', run: (context) => { context.sendScriptedRequest({ content: 'Show me the agent trace.' }); context.schedule( @@ -289,7 +314,7 @@ export const CHAT_STORY_SCENARIOS = { respond(context.service, { content: 'Trace', widget: APOLLO_CHAT_TREE_RENDERER, - meta: { span: storyTraceSpan() }, + meta: { span: storyTreeSpan() }, }), context.delayMs ); diff --git a/packages/apollo-react/src/chat/wind/renderers/disclosure-tree.tsx b/packages/apollo-react/src/chat/wind/renderers/disclosure-tree.tsx new file mode 100644 index 0000000000..13f142a1a9 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/renderers/disclosure-tree.tsx @@ -0,0 +1,138 @@ +import { cn, Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from '@uipath/apollo-wind'; +import { ChevronRight } from 'lucide-react'; +import React from 'react'; + +export interface DisclosureTreeItem { + id: string; + label: React.ReactNode; + icon?: React.ReactNode; + /** Shown as a tooltip on the row, as Material does; no tooltip without one. */ + description?: string; + /** Right-aligned secondary text. */ + trailing?: React.ReactNode; + labelStyle?: React.CSSProperties; + defaultOpen?: boolean; + /** The children, or a function that builds them when the row first opens. */ + children?: readonly DisclosureTreeItem[] | (() => readonly DisclosureTreeItem[]); + /** Whether the row has children; required when `children` is a function. */ + expandable?: boolean; +} + +/** Row open state held by the caller, so it survives the tree unmounting. */ +export interface DisclosureTreeOpenState { + isOpen: (id: string) => boolean; + setOpen: (id: string, open: boolean) => void; +} + +// The tool call section toggle's treatment (apollo-wind `ToolCallSection`), which needs a +// `ToolCall` around it, so a tree message reads as the same kind of drill-down. +const ROW_CLASSES = + 'flex w-full min-w-0 items-center gap-2 rounded p-1 text-left transition-colors motion-reduce:transition-none'; +const FOCUS_CLASSES = 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring'; +const TOGGLE_CLASSES = cn('cursor-pointer hover:text-foreground', FOCUS_CLASSES); + +/** + * A nested list whose parent rows disclose their children, for agent traces and tree messages, + * styled like the tool call's sections. Each parent row is a button with `aria-expanded`; + * children render only while open, indented under the row's label. Pass `openState` to keep + * which rows are open outside the tree. + */ +export function DisclosureTree({ + items, + openState, + className, +}: { + items: readonly DisclosureTreeItem[]; + openState?: DisclosureTreeOpenState; + className?: string; +}) { + return ( +
    + {items.map((item) => ( + + ))} +
+ ); +} + +function DisclosureTreeRow({ + item, + openState, +}: { + item: DisclosureTreeItem; + openState?: DisclosureTreeOpenState; +}) { + const [localOpen, setLocalOpen] = React.useState(item.defaultOpen ?? false); + const open = openState ? openState.isOpen(item.id) : localOpen; + const setOpen = (next: boolean) => + openState ? openState.setOpen(item.id, next) : setLocalOpen(next); + const hasChildren = + typeof item.children === 'function' ? !!item.expandable : !!item.children?.length; + const children = hasChildren && open ? resolveChildren(item.children) : undefined; + + const content = ( + + {item.icon && ( + + {item.icon} + + )} + + {item.label} + + {item.trailing && ( + {item.trailing} + )} + + ); + const chevron = ( +