Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/apollo-react/src/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 2 additions & 17 deletions packages/apollo-react/src/chat/headless/markdown/citations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AutopilotChatMessage, PdfCitation, UrlCitation } from '../../service';
import { isOpenableUrl } from '../utils/openable-url';
Comment thread
BenGSchulz marked this conversation as resolved.

/** The props `citationPlugin` gives each `citation` node. */
export interface CitationMarker {
Expand All @@ -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.
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -58,36 +58,26 @@ const API_WORKFLOW_TASK_ICON_MAP = new Map<string, string>([
['WorkflowStart', 'workflow-start-icon'],
]);

function getAttributes(span: TSpan): Record<string, any> {
const attrs = span.attributes;
if (typeof attrs === 'string') {
try {
return JSON.parse(attrs);
} catch {
return {};
}
}
return (attrs as Record<string, any>) ?? {};
}

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);
if (simpleKey) {
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';
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<ToolCallSpanNode['data']> = {},
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();
});
});
Loading
Loading