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 = ( +