@adecore/agents-react 0.17.0-beta.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +44 -36
  3. package/dist/chat/chat-client.d.ts +2 -1
  4. package/dist/chat/chat-client.js +8 -2
  5. package/dist/chat/logic/ansi.js +2 -2
  6. package/dist/chat/logic/fork.js +2 -0
  7. package/dist/chat/logic/thread-cards.d.ts +9 -0
  8. package/dist/chat/logic/thread-cards.js +35 -13
  9. package/dist/chat/logic/timeline.d.ts +5 -1
  10. package/dist/chat/logic/timeline.js +1 -1
  11. package/dist/chat/logic/visual-height.d.ts +4 -0
  12. package/dist/chat/logic/visual-height.js +32 -0
  13. package/dist/chat/ui/Scrubber.js +1 -1
  14. package/dist/chat/ui/Timeline.js +8 -3
  15. package/dist/chat/ui/UnifiedDiff.js +2 -2
  16. package/dist/chat/ui/VisualDialogs.d.ts +3 -0
  17. package/dist/chat/ui/VisualDialogs.js +32 -0
  18. package/dist/chat/ui/VisualFrame.d.ts +8 -0
  19. package/dist/chat/ui/VisualFrame.js +95 -0
  20. package/dist/chat/ui/rows/Rows.js +3 -0
  21. package/dist/chat/ui/rows/VisualRow.d.ts +5 -0
  22. package/dist/chat/ui/rows/VisualRow.js +23 -0
  23. package/dist/chat/ui/rows/WorkRows.js +4 -4
  24. package/dist/chat/ui/visual-theme.d.ts +10 -0
  25. package/dist/chat/ui/visual-theme.js +172 -0
  26. package/dist/chat/visual-bridge.d.ts +37 -0
  27. package/dist/chat/visual-bridge.js +129 -0
  28. package/dist/chat/visuals.d.ts +9 -0
  29. package/dist/chat/visuals.js +8 -0
  30. package/dist/host.d.ts +5 -0
  31. package/dist/host.js +2 -1
  32. package/dist/locales/en/agent-chat.json +12 -0
  33. package/dist/locales/nl/agent-chat.json +12 -0
  34. package/dist/state/chats.d.ts +4 -1
  35. package/dist/state/chats.js +14 -4
  36. package/dist/theme.css +32 -1
  37. package/package.json +38 -3
  38. package/src/chat/chat-client.ts +10 -1
  39. package/src/chat/logic/ansi.ts +2 -2
  40. package/src/chat/logic/fork.ts +2 -0
  41. package/src/chat/logic/thread-cards.ts +46 -13
  42. package/src/chat/logic/timeline.ts +5 -2
  43. package/src/chat/logic/visual-height.ts +38 -0
  44. package/src/chat/ui/Scrubber.tsx +1 -4
  45. package/src/chat/ui/Timeline.tsx +11 -2
  46. package/src/chat/ui/UnifiedDiff.tsx +2 -2
  47. package/src/chat/ui/VisualDialogs.tsx +64 -0
  48. package/src/chat/ui/VisualFrame.tsx +136 -0
  49. package/src/chat/ui/rows/Rows.tsx +3 -0
  50. package/src/chat/ui/rows/VisualRow.tsx +47 -0
  51. package/src/chat/ui/rows/WorkRows.tsx +6 -6
  52. package/src/chat/ui/visual-theme.ts +197 -0
  53. package/src/chat/visual-bridge.ts +176 -0
  54. package/src/chat/visuals.ts +22 -0
  55. package/src/host.ts +12 -1
  56. package/src/locales/en/agent-chat.json +12 -0
  57. package/src/locales/nl/agent-chat.json +12 -0
  58. package/src/state/chats.ts +19 -5
  59. package/src/theme.css +32 -1
@@ -34,7 +34,7 @@ function ToolBody({ tool }) {
34
34
  const { t } = useTranslation('agent-chat');
35
35
  const patches = unifiedChanges(tool);
36
36
  const changes = patches.length > 0 ? [] : fileChanges(tool.name, tool.input);
37
- return (_jsxs("div", { className: "mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised", children: [patches.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, index))) })) : changes.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: changes.map((change, index) => (_jsx(EditDiff, { change: change }, index))) })) : (_jsx("pre", { className: "max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text", children: clip(JSON.stringify(tool.input, null, 2) ?? '') })), tool.output !== null && tool.output !== '' && patches.length === 0 && (_jsx("pre", { "data-find-field": "output", className: clsx('max-h-64 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-code select-text', tool.state === 'error' ? 'text-term-red' : 'text-term-fg'), children: _jsx(AnsiOutput, { text: tool.output, limit: OUTPUT_LIMIT }) }))] }));
37
+ return (_jsxs("div", { className: "mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised", children: [patches.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, index))) })) : changes.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: changes.map((change, index) => (_jsx(EditDiff, { change: change }, index))) })) : (_jsx("pre", { className: "max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text", children: clip(JSON.stringify(tool.input, null, 2) ?? '') })), tool.output !== null && tool.output !== '' && patches.length === 0 && (_jsx("pre", { "data-find-field": "output", className: clsx('max-h-64 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-code select-text', tool.state === 'error' ? 'text-chat-removed' : 'text-chat-output'), children: _jsx(AnsiOutput, { text: tool.output, limit: OUTPUT_LIMIT }) }))] }));
38
38
  }
39
39
  /* One settled tool call: a line, and its input and output behind it. An image the call looked at
40
40
  is drawn under the line, because a picture says more about that read than its path does. */
@@ -56,7 +56,7 @@ export function WorkLiveRow({ chatId, tool: derived }) {
56
56
  const [open, setOpen] = useState(false);
57
57
  const tool = useCurrentItem(chatId, derived);
58
58
  const tail = liveOutput(tool);
59
- return (_jsxs("div", { children: [_jsx(ToggleLine, { icon: toolIcon(tool.name), label: tool.name, detail: toolSummary(tool.name, tool.input) || tool.progress?.description || '', open: open, onToggle: () => setOpen((o) => !o), trailing: _jsx(RunningFor, { startedAt: toolStartedAt(tool) }), live: true }), open && _jsx(ToolBody, { tool: tool }), tail !== null && !open && (_jsx("pre", { className: "mt-1.5 mb-2 ml-6 max-h-48 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 font-mono text-code whitespace-pre-wrap text-term-fg select-text", children: _jsx(AnsiOutput, { text: tail, tailLines: 12 }) }))] }));
59
+ return (_jsxs("div", { children: [_jsx(ToggleLine, { icon: toolIcon(tool.name), label: tool.name, detail: toolSummary(tool.name, tool.input) || tool.progress?.description || '', open: open, onToggle: () => setOpen((o) => !o), trailing: _jsx(RunningFor, { startedAt: toolStartedAt(tool) }), live: true }), open && _jsx(ToolBody, { tool: tool }), tail !== null && !open && (_jsx("pre", { className: "mt-1.5 mb-2 ml-6 max-h-48 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 font-mono text-code whitespace-pre-wrap text-chat-output select-text", children: _jsx(AnsiOutput, { text: tail, tailLines: 12 }) }))] }));
60
60
  }
61
61
  /* A run of tool calls as one line ("Read 4 files"); opening it lists every call. */
62
62
  export function WorkGroupRow({ tools, summary, expanded, onToggle }) {
@@ -91,7 +91,7 @@ function ChangedFilesCard({ count, truncated, added, deleted, revealNonce = 0, c
91
91
  setSeenReveal(revealNonce);
92
92
  setExpanded(true);
93
93
  }
94
- return (_jsxs("div", { className: "mb-3 overflow-hidden rounded-lg border border-border bg-surface-raised", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-text-muted hover:bg-surface-hover", "aria-expanded": expanded, onClick: () => setExpanded((value) => !value), children: [_jsx(Icon, { icon: FileDiff, size: 12, className: "shrink-0" }), _jsx("span", { className: "shrink-0 font-medium text-text", children: t('work.changedFiles', { count }) }), truncated && _jsx("span", { className: "min-w-0 truncate text-text-faint", children: t('work.andMore') }), _jsx("span", { className: "grow" }), added !== undefined && _jsxs("span", { className: "text-term-green tabular-nums", children: ["+", added] }), deleted !== undefined && _jsxs("span", { className: "text-term-red tabular-nums", children: ["-", deleted] }), _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', expanded && 'rotate-90') })] }), expanded && children] }));
94
+ return (_jsxs("div", { className: "mb-3 overflow-hidden rounded-lg border border-border bg-surface-raised", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-text-muted hover:bg-surface-hover", "aria-expanded": expanded, onClick: () => setExpanded((value) => !value), children: [_jsx(Icon, { icon: FileDiff, size: 12, className: "shrink-0" }), _jsx("span", { className: "shrink-0 font-medium text-text", children: t('work.changedFiles', { count }) }), truncated && _jsx("span", { className: "min-w-0 truncate text-text-faint", children: t('work.andMore') }), _jsx("span", { className: "grow" }), added !== undefined && _jsxs("span", { className: "text-chat-added tabular-nums", children: ["+", added] }), deleted !== undefined && _jsxs("span", { className: "text-chat-removed tabular-nums", children: ["-", deleted] }), _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', expanded && 'rotate-90') })] }), expanded && children] }));
95
95
  }
96
96
  /*
97
97
  * The files a settled turn changed, as a card; each file opens its diff in place. The host's
@@ -126,7 +126,7 @@ export function ChangedFilesRow({ tools, diff, checkpoint, chatId, turnId }) {
126
126
  const revealNonce = flash !== null && flash.opens && flash.key === keyOf(chatId) && flash.rowId === `files-${turnId}` ? flash.nonce : 0;
127
127
  if (checkpointDiff !== null && checkpointDiff.files.length > 0) {
128
128
  const files = checkpointDiff.files;
129
- return (_jsx(ChangedFilesCard, { revealNonce: revealNonce, count: files.length, truncated: checkpointDiff.truncated, added: files.reduce((sum, file) => sum + file.added, 0), deleted: files.reduce((sum, file) => sum + file.deleted, 0), children: files.map((file) => (_jsxs("div", { className: "border-t border-border", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover", "data-file-path": file.path, onClick: () => setOpen((o) => ({ ...o, [file.path]: !o[file.path] })), children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open[file.path] && 'rotate-90') }), _jsx("span", { className: "min-w-0 truncate font-mono text-text", children: file.path }), _jsx("span", { className: "grow" }), _jsxs("span", { className: "text-term-green tabular-nums", children: ["+", file.added] }), _jsxs("span", { className: "text-term-red tabular-nums", children: ["-", file.deleted] })] }), open[file.path] && (_jsx("div", { className: "border-t border-border", children: _jsx(CheckpointFileBody, { file: file }) }))] }, file.path))) }));
129
+ return (_jsx(ChangedFilesCard, { revealNonce: revealNonce, count: files.length, truncated: checkpointDiff.truncated, added: files.reduce((sum, file) => sum + file.added, 0), deleted: files.reduce((sum, file) => sum + file.deleted, 0), children: files.map((file) => (_jsxs("div", { className: "border-t border-border", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover", "data-file-path": file.path, onClick: () => setOpen((o) => ({ ...o, [file.path]: !o[file.path] })), children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open[file.path] && 'rotate-90') }), _jsx("span", { className: "min-w-0 truncate font-mono text-text", children: file.path }), _jsx("span", { className: "grow" }), _jsxs("span", { className: "text-chat-added tabular-nums", children: ["+", file.added] }), _jsxs("span", { className: "text-chat-removed tabular-nums", children: ["-", file.deleted] })] }), open[file.path] && (_jsx("div", { className: "border-t border-border", children: _jsx(CheckpointFileBody, { file: file }) }))] }, file.path))) }));
130
130
  }
131
131
  return _jsx(ProviderChangedFiles, { tools: tools, open: open, setOpen: setOpen, revealNonce: revealNonce });
132
132
  }
@@ -0,0 +1,10 @@
1
+ import { type RefObject } from 'react';
2
+ import { type VisualAppearance, type VisualTheme } from '@adecore/agent-contracts/visual';
3
+ export interface VisualTokens {
4
+ color(token: string): string;
5
+ length(token: string): string;
6
+ value(token: string): string;
7
+ }
8
+ export declare function visualThemeOf(tokens: VisualTokens, appearance: VisualAppearance, background: string): VisualTheme;
9
+ export declare function backgroundBehind(layers: readonly string[]): string | null;
10
+ export declare function useVisualTheme(ref: RefObject<HTMLElement | null>): VisualTheme | null;
@@ -0,0 +1,172 @@
1
+ import { useLayoutEffect, useState } from 'react';
2
+ import { cleanVisualVariables } from '@adecore/agent-contracts/visual';
3
+ import { chatHost } from '../../host.js';
4
+ /*
5
+ * A page's theme from the app's tokens. The names follow common component themes, where `--accent`
6
+ * is a quiet hover ground and `--primary` the color that stands out, so the app's accent is the
7
+ * primary, the ring and the first series of a chart. A variable without a token here, or with a
8
+ * token the app does not set, keeps the page's default for this appearance.
9
+ */
10
+ export function visualThemeOf(tokens, appearance, background) {
11
+ const text = tokens.color('--text');
12
+ const quiet = tokens.color('--surface-hover');
13
+ const raised = tokens.color('--surface-raised');
14
+ const accent = tokens.color('--accent');
15
+ const onAccent = tokens.color('--accent-text');
16
+ const variables = {
17
+ '--background': background,
18
+ '--foreground': text,
19
+ '--muted': quiet,
20
+ '--muted-foreground': tokens.color('--text-muted'),
21
+ '--card': raised,
22
+ '--card-foreground': text,
23
+ '--popover': raised,
24
+ '--popover-foreground': text,
25
+ '--secondary': quiet,
26
+ '--secondary-foreground': text,
27
+ '--border': tokens.color('--border'),
28
+ '--input': tokens.color('--border-strong'),
29
+ '--ring': accent,
30
+ '--primary': accent,
31
+ '--primary-foreground': onAccent,
32
+ '--accent': quiet,
33
+ '--accent-foreground': text,
34
+ '--destructive': tokens.color('--status-error'),
35
+ '--destructive-foreground': onAccent,
36
+ '--warning': tokens.color('--status-needs-you'),
37
+ '--success': tokens.color('--positive'),
38
+ '--success-foreground': tokens.color('--positive-text'),
39
+ '--info': tokens.color('--status-running'),
40
+ '--code-background': tokens.color('--chat-code-bg'),
41
+ '--code-foreground': tokens.color('--chat-output'),
42
+ '--chart-1': tokens.color('--chart-1') || accent,
43
+ '--chart-2': tokens.color('--chart-2'),
44
+ '--chart-3': tokens.color('--chart-3'),
45
+ '--chart-4': tokens.color('--chart-4'),
46
+ '--chart-5': tokens.color('--chart-5'),
47
+ '--chart-6': tokens.color('--chart-6'),
48
+ '--radius': tokens.length('--radius-lg'),
49
+ '--font-sans': tokens.value('--font-sans'),
50
+ '--font-mono': tokens.value('--font-mono')
51
+ };
52
+ return { appearance, variables: cleanVisualVariables(variables) };
53
+ }
54
+ const RGB = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/;
55
+ function rgbaOf(color) {
56
+ const match = RGB.exec(color.trim());
57
+ if (match === null) {
58
+ return null;
59
+ }
60
+ const alpha = match[4] === undefined ? 1 : Number(match[4]) / (match[5] === '%' ? 100 : 1);
61
+ return { red: Number(match[1]), green: Number(match[2]), blue: Number(match[3]), alpha };
62
+ }
63
+ function isTransparent(color) {
64
+ return color === 'transparent' || rgbaOf(color)?.alpha === 0;
65
+ }
66
+ /*
67
+ * What lies behind an element, from the computed backgrounds of it and its ancestors, nearest first:
68
+ * the first one that covers what is under it, with every translucent one in front of it laid over it.
69
+ * Null when none of them paints.
70
+ */
71
+ export function backgroundBehind(layers) {
72
+ const translucent = [];
73
+ for (const layer of layers) {
74
+ if (layer === '' || isTransparent(layer)) {
75
+ continue;
76
+ }
77
+ const color = rgbaOf(layer);
78
+ if (color !== null && color.alpha < 1) {
79
+ translucent.push(color);
80
+ continue;
81
+ }
82
+ if (color === null || translucent.length === 0) {
83
+ // A color in a space other than sRGB stays as the engine wrote it, since there is no laying another over it here.
84
+ return color === null ? layer : `rgb(${color.red}, ${color.green}, ${color.blue})`;
85
+ }
86
+ let { red, green, blue } = color;
87
+ for (const over of translucent.reverse()) {
88
+ red = over.red * over.alpha + red * (1 - over.alpha);
89
+ green = over.green * over.alpha + green * (1 - over.alpha);
90
+ blue = over.blue * over.alpha + blue * (1 - over.alpha);
91
+ }
92
+ return `rgb(${Math.round(red)}, ${Math.round(green)}, ${Math.round(blue)})`;
93
+ }
94
+ return null;
95
+ }
96
+ function sameTheme(left, right) {
97
+ return left.appearance === right.appearance && JSON.stringify(left.variables) === JSON.stringify(right.variables);
98
+ }
99
+ /*
100
+ * The theme where `element` stands. Colors and lengths are read off a probe in that very place, so a
101
+ * nested theme answers for itself and every value comes out the way the engine computes it.
102
+ */
103
+ function readTheme(element, mode) {
104
+ const view = element.ownerDocument.defaultView ?? window;
105
+ const style = view.getComputedStyle(element);
106
+ const probe = element.ownerDocument.createElement('span');
107
+ probe.style.position = 'absolute';
108
+ probe.style.visibility = 'hidden';
109
+ element.append(probe);
110
+ try {
111
+ const probed = view.getComputedStyle(probe);
112
+ const declared = (token) => style.getPropertyValue(token).trim();
113
+ const computed = (property, token) => {
114
+ if (declared(token) === '') {
115
+ return '';
116
+ }
117
+ probe.style[property] = `var(${token})`;
118
+ return probed[property];
119
+ };
120
+ const tokens = {
121
+ color: (token) => computed('color', token),
122
+ length: (token) => computed('borderTopLeftRadius', token),
123
+ value: declared
124
+ };
125
+ const layers = [];
126
+ for (let node = element; node !== null; node = node.parentElement) {
127
+ layers.push(view.getComputedStyle(node).backgroundColor);
128
+ }
129
+ const scheme = style.colorScheme.trim();
130
+ const appearance = scheme === 'light' || scheme === 'dark' ? scheme : mode;
131
+ return visualThemeOf(tokens, appearance, backgroundBehind(layers) ?? tokens.color('--surface'));
132
+ }
133
+ finally {
134
+ probe.remove();
135
+ }
136
+ }
137
+ /* The attributes an app switches its theme or its accent with. */
138
+ const THEME_ATTRIBUTES = ['class', 'style', 'data-theme'];
139
+ /*
140
+ * The app's theme for a visual drawn in `ref`, null until the element is there. It is read again when
141
+ * the app turns light or dark, and when the root of the document changes a theme attribute, so an
142
+ * accent or a font a person picks reaches every frame as well.
143
+ */
144
+ export function useVisualTheme(ref) {
145
+ const mode = chatHost().code.useMode();
146
+ const [theme, setTheme] = useState(null);
147
+ useLayoutEffect(() => {
148
+ const element = ref.current;
149
+ if (element === null) {
150
+ return;
151
+ }
152
+ const read = () => {
153
+ const next = readTheme(element, mode);
154
+ setTheme((current) => (current !== null && sameTheme(current, next) ? current : next));
155
+ };
156
+ read();
157
+ const owner = element.ownerDocument;
158
+ const observer = new MutationObserver(read);
159
+ for (const root of [owner.documentElement, owner.body]) {
160
+ if (root !== null) {
161
+ observer.observe(root, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
162
+ }
163
+ }
164
+ const scheme = owner.defaultView?.matchMedia?.('(prefers-color-scheme: dark)') ?? null;
165
+ scheme?.addEventListener('change', read);
166
+ return () => {
167
+ observer.disconnect();
168
+ scheme?.removeEventListener('change', read);
169
+ };
170
+ }, [ref, mode]);
171
+ return theme;
172
+ }
@@ -0,0 +1,37 @@
1
+ import { type ChatVisual, type VisualAppearance, type VisualTheme } from '@adecore/agent-contracts/visual';
2
+ export interface VisualFrameWindow {
3
+ postMessage(message: unknown, targetOrigin: string): void;
4
+ }
5
+ export interface VisualBridgeOptions {
6
+ frame(): VisualFrameWindow | null;
7
+ visual: Pick<ChatVisual, 'maxHeight'>;
8
+ page: Promise<string>;
9
+ theme: VisualTheme;
10
+ mayOpenLink(): boolean;
11
+ openLink(url: string): void;
12
+ onHeight(height: number): void;
13
+ onAppearance(appearance: VisualAppearance): void;
14
+ onFailure(): void;
15
+ after?(ms: number, run: () => void): () => void;
16
+ }
17
+ export declare class VisualBridge {
18
+ private readonly options;
19
+ private theme;
20
+ private html;
21
+ private heard;
22
+ private waiting;
23
+ private sent;
24
+ private live;
25
+ private disposed;
26
+ private cancelSilence;
27
+ constructor(options: VisualBridgeOptions);
28
+ receive(event: {
29
+ source: unknown;
30
+ data: unknown;
31
+ }): void;
32
+ setTheme(theme: VisualTheme): void;
33
+ loaded(): void;
34
+ dispose(): void;
35
+ private sendPage;
36
+ private postTheme;
37
+ }
@@ -0,0 +1,129 @@
1
+ import { VISUAL_BRIDGE_METHODS, parseVisualMessage, visualHostContextMessage, visualOpenLinkResult, visualResourceReadyMessage } from '@adecore/agent-contracts/visual';
2
+ import { clampVisualHeight } from './logic/visual-height.js';
3
+ /*
4
+ * How long a frame that finished loading may stay silent before it counts as failed. The sandbox host
5
+ * page speaks while it loads, so silence means the address served something else; the margin is for
6
+ * its message and the load event, which cross between processes and may arrive in either order.
7
+ */
8
+ const SILENCE_MS = 3000;
9
+ function timeout(ms, run) {
10
+ const timer = setTimeout(run, ms);
11
+ return () => clearTimeout(timer);
12
+ }
13
+ /*
14
+ * Talks to the frame of one visual. It hands the sandbox host page the visual's page when it says it
15
+ * listens, keeps the page in the app's theme, follows the size the page reports and opens the links a
16
+ * person follows in it. A message counts only when it comes from the frame's own window, and the
17
+ * messages that come from the page count only once the page was sent.
18
+ */
19
+ export class VisualBridge {
20
+ options;
21
+ theme;
22
+ html = null;
23
+ /* Whether the sandbox host page ever said it listens, which is what tells it from an error page. */
24
+ heard = false;
25
+ /* The sandbox host page listens and has not been sent the page yet. */
26
+ waiting = false;
27
+ /* The theme the page was last sent, from the moment it was sent; null before that. */
28
+ sent = null;
29
+ /* Whether the page reported a size since it was sent, which says it runs and is drawn. */
30
+ live = false;
31
+ disposed = false;
32
+ cancelSilence = () => undefined;
33
+ constructor(options) {
34
+ this.options = options;
35
+ this.theme = options.theme;
36
+ options.page.then((html) => {
37
+ this.html = html;
38
+ this.sendPage();
39
+ }, () => {
40
+ if (!this.disposed) {
41
+ options.onFailure();
42
+ }
43
+ });
44
+ }
45
+ /* A `message` event of the window the frame is in. */
46
+ receive(event) {
47
+ const frame = this.options.frame();
48
+ if (this.disposed || frame === null || event.source !== frame) {
49
+ return;
50
+ }
51
+ const message = parseVisualMessage(event.data);
52
+ switch (message?.method) {
53
+ case VISUAL_BRIDGE_METHODS.sandboxProxyReady:
54
+ // A frame that loads again starts over from the sandbox host page.
55
+ this.heard = true;
56
+ this.waiting = true;
57
+ this.sent = null;
58
+ this.live = false;
59
+ this.cancelSilence();
60
+ this.sendPage();
61
+ return;
62
+ case VISUAL_BRIDGE_METHODS.sizeChanged:
63
+ if (this.sent === null) {
64
+ return;
65
+ }
66
+ if (!this.live) {
67
+ this.live = true;
68
+ this.options.onAppearance(this.sent.appearance);
69
+ }
70
+ this.options.onHeight(clampVisualHeight(this.options.visual, message.height));
71
+ return;
72
+ case VISUAL_BRIDGE_METHODS.openLink:
73
+ if (this.sent === null) {
74
+ return;
75
+ }
76
+ if (this.options.mayOpenLink()) {
77
+ this.options.openLink(message.url);
78
+ }
79
+ frame.postMessage(visualOpenLinkResult(message.id), '*');
80
+ return;
81
+ default:
82
+ return;
83
+ }
84
+ }
85
+ /* The app's theme changed; a page that was sent follows it at once, without loading again. */
86
+ setTheme(theme) {
87
+ this.theme = theme;
88
+ const frame = this.options.frame();
89
+ if (this.disposed || this.sent === null || frame === null) {
90
+ return;
91
+ }
92
+ this.postTheme(frame);
93
+ if (this.live) {
94
+ this.options.onAppearance(theme.appearance);
95
+ }
96
+ }
97
+ /* The frame fired `load`. */
98
+ loaded() {
99
+ if (this.heard || this.disposed) {
100
+ return;
101
+ }
102
+ this.cancelSilence();
103
+ this.cancelSilence = (this.options.after ?? timeout)(SILENCE_MS, () => {
104
+ if (!this.heard && !this.disposed) {
105
+ this.options.onFailure();
106
+ }
107
+ });
108
+ }
109
+ dispose() {
110
+ this.disposed = true;
111
+ this.cancelSilence();
112
+ }
113
+ sendPage() {
114
+ const frame = this.options.frame();
115
+ if (this.disposed || !this.waiting || this.html === null || frame === null) {
116
+ return;
117
+ }
118
+ this.waiting = false;
119
+ // Any origin, since the frame's own is opaque; `receive` checks every answer's source instead.
120
+ frame.postMessage(visualResourceReadyMessage(this.html), '*');
121
+ // The page writes itself as that message arrives and listens before the next one, so a theme
122
+ // that changed since the frame's address was set follows right after it.
123
+ this.postTheme(frame);
124
+ }
125
+ postTheme(frame) {
126
+ this.sent = this.theme;
127
+ frame.postMessage(visualHostContextMessage(this.theme), '*');
128
+ }
129
+ }
@@ -0,0 +1,9 @@
1
+ interface VisualDialog {
2
+ chatKey: string | null;
3
+ visualId: string | null;
4
+ kind: 'expand' | 'remove' | null;
5
+ open(chatKey: string, visualId: string, kind: 'expand' | 'remove'): void;
6
+ close(): void;
7
+ }
8
+ export declare const useVisualDialog: import("zustand").UseBoundStore<import("zustand").StoreApi<VisualDialog>>;
9
+ export {};
@@ -0,0 +1,8 @@
1
+ import { create } from 'zustand';
2
+ export const useVisualDialog = create((set) => ({
3
+ chatKey: null,
4
+ visualId: null,
5
+ kind: null,
6
+ open: (chatKey, visualId, kind) => set({ chatKey, visualId, kind }),
7
+ close: () => set({ chatKey: null, visualId: null, kind: null })
8
+ }));
package/dist/host.d.ts CHANGED
@@ -106,6 +106,10 @@ export interface ThreadCard {
106
106
  at: number;
107
107
  render(): ReactNode;
108
108
  }
109
+ export interface VisualHost {
110
+ frameUrl: string;
111
+ openLink(url: string): void;
112
+ }
109
113
  export interface ReplyAuthor {
110
114
  name: string;
111
115
  mark?: ReactNode;
@@ -190,6 +194,7 @@ export interface ChatHost {
190
194
  SubagentSlot: ComponentType<SubagentSlotProps> | null;
191
195
  useThreadCards(scopeId: string, chatId: string): readonly ThreadCard[];
192
196
  useReplyAuthor(scopeId: string, chatId: string): ReplyAuthor | null;
197
+ visuals: VisualHost | null;
193
198
  }
194
199
  export declare function setChatHost(patch: Partial<ChatHost>): void;
195
200
  export declare function chatHost(): ChatHost;
package/dist/host.js CHANGED
@@ -41,7 +41,8 @@ const DEFAULT_HOST = {
41
41
  ComposerSlot: null,
42
42
  SubagentSlot: null,
43
43
  useThreadCards: () => NO_CARDS,
44
- useReplyAuthor: () => null
44
+ useReplyAuthor: () => null,
45
+ visuals: null
45
46
  };
46
47
  let host = DEFAULT_HOST;
47
48
  export function setChatHost(patch) {
@@ -416,6 +416,18 @@
416
416
  "renameFailed": "The bookmark could not be renamed",
417
417
  "removeFailed": "The bookmark could not be removed"
418
418
  },
419
+ "visuals": {
420
+ "actions": "Actions for {{title}}",
421
+ "expand": "Open large",
422
+ "more": "More actions",
423
+ "remove": "Remove visual",
424
+ "removeTitle": "Remove this visual?",
425
+ "removeDescription": "{{title}} is deleted from the chat. This cannot be undone.",
426
+ "removeFailed": "The visual could not be removed",
427
+ "loading": "Loading {{title}}",
428
+ "failed": "Could not load {{title}}",
429
+ "renderFailed": "This visual failed to render"
430
+ },
419
431
  "image": {
420
432
  "zoomIn": "Zoom in",
421
433
  "zoomOut": "Zoom out",
@@ -416,6 +416,18 @@
416
416
  "renameFailed": "De bladwijzer kon niet worden hernoemd",
417
417
  "removeFailed": "De bladwijzer kon niet worden verwijderd"
418
418
  },
419
+ "visuals": {
420
+ "actions": "Acties voor {{title}}",
421
+ "expand": "Groot openen",
422
+ "more": "Meer acties",
423
+ "remove": "Visualisatie verwijderen",
424
+ "removeTitle": "Deze visualisatie verwijderen?",
425
+ "removeDescription": "{{title}} wordt uit de chat verwijderd. Dit kan niet ongedaan worden gemaakt.",
426
+ "removeFailed": "De visualisatie kon niet worden verwijderd",
427
+ "loading": "{{title}} wordt geladen",
428
+ "failed": "{{title}} kon niet worden geladen",
429
+ "renderFailed": "Deze visualisatie kon niet worden getekend"
430
+ },
419
431
  "image": {
420
432
  "zoomIn": "Inzoomen",
421
433
  "zoomOut": "Uitzoomen",
@@ -1,4 +1,4 @@
1
- import type { ChatApprovalItem, ChatBookmark, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
1
+ import type { ChatApprovalItem, ChatBookmark, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem, ChatVisual } from '@adecore/agent-contracts';
2
2
  export interface ChatState {
3
3
  info: ChatInfo;
4
4
  items: Record<string, ChatItem>;
@@ -7,6 +7,7 @@ export interface ChatState {
7
7
  history?: ChatHistoryPage;
8
8
  waitingBefore?: string[];
9
9
  bookmarks?: ChatBookmark[];
10
+ visuals?: ChatVisual[];
10
11
  }
11
12
  export type ChatHistoryPage = ChatHistoryResult['history'];
12
13
  export interface ChatPage {
@@ -21,6 +22,7 @@ export interface ChatSink {
21
22
  apply(chatId: string, event: ChatEvent): void;
22
23
  status(chatId: string, info: ChatInfo): void;
23
24
  bookmarks(chatId: string, bookmarks: ChatBookmark[]): void;
25
+ visuals(chatId: string, visuals: ChatVisual[]): void;
24
26
  forget(chatId: string): void;
25
27
  }
26
28
  interface ChatsStore {
@@ -31,6 +33,7 @@ interface ChatsStore {
31
33
  apply(key: string, event: ChatEvent): void;
32
34
  status(key: string, info: ChatInfo): void;
33
35
  bookmarks(key: string, bookmarks: ChatBookmark[]): void;
36
+ visuals(key: string, visuals: ChatVisual[]): void;
34
37
  forget(key: string): void;
35
38
  forgetWhere(matches: (key: string) => boolean): void;
36
39
  }
@@ -105,11 +105,11 @@ export const useChats = create((set) => ({
105
105
  statusByKey: {},
106
106
  reset(key, info, items, page) {
107
107
  set((s) => {
108
- // The bookmarks are not part of the thread; they come in on their own and outlive a reset of it.
109
- const bookmarks = s.byKey[key]?.bookmarks;
110
- const next = stateOf(info, items, page);
108
+ // The bookmarks and the visuals are not part of the thread; they come in on their own and outlive a reset of it.
109
+ const { bookmarks, visuals } = s.byKey[key] ?? {};
110
+ const next = { ...stateOf(info, items, page), ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
111
111
  return {
112
- byKey: { ...s.byKey, [key]: bookmarks === undefined ? next : { ...next, bookmarks } },
112
+ byKey: { ...s.byKey, [key]: next },
113
113
  statusByKey: withStatus(s.statusByKey, key, info)
114
114
  };
115
115
  });
@@ -161,6 +161,15 @@ export const useChats = create((set) => ({
161
161
  return { byKey: { ...s.byKey, [key]: { ...current, bookmarks } } };
162
162
  });
163
163
  },
164
+ visuals(key, visuals) {
165
+ set((s) => {
166
+ const current = s.byKey[key];
167
+ if (!current) {
168
+ return {};
169
+ }
170
+ return { byKey: { ...s.byKey, [key]: { ...current, visuals } } };
171
+ });
172
+ },
164
173
  forget(key) {
165
174
  set((s) => {
166
175
  const next = { ...s.byKey };
@@ -182,6 +191,7 @@ export function chatSink(keyOf) {
182
191
  apply: (chatId, event) => useChats.getState().apply(keyOf(chatId), event),
183
192
  status: (chatId, info) => useChats.getState().status(keyOf(chatId), info),
184
193
  bookmarks: (chatId, bookmarks) => useChats.getState().bookmarks(keyOf(chatId), bookmarks),
194
+ visuals: (chatId, visuals) => useChats.getState().visuals(keyOf(chatId), visuals),
185
195
  forget: (chatId) => useChats.getState().forget(keyOf(chatId))
186
196
  };
187
197
  }
package/dist/theme.css CHANGED
@@ -18,6 +18,15 @@
18
18
  --chart-context-files: #9a63e0;
19
19
  --chart-context-conversation: #2f9e5a;
20
20
  --chart-context-system: var(--text-faint);
21
+ /* The current hit of a find, which has to stand out from the accent of a bookmark next to it. */
22
+ --chat-find: #facc15;
23
+ /* The series of a chart in a visual after the first, which is the accent. A categorical order checked
24
+ for color vision deficiency on both grounds: orange, aqua, yellow, magenta, green. */
25
+ --chart-2: #eb6834;
26
+ --chart-3: #1baf7a;
27
+ --chart-4: #eda100;
28
+ --chart-5: #e87ba4;
29
+ --chart-6: #008300;
21
30
  }
22
31
 
23
32
  [data-theme="dark"] {
@@ -31,6 +40,22 @@
31
40
  --chart-context-files: #b98af3;
32
41
  --chart-context-conversation: #6dd08b;
33
42
  --chart-context-system: var(--text-faint);
43
+ --chat-find: #f5c542;
44
+ --chart-2: #d95926;
45
+ --chart-3: #199e70;
46
+ --chart-4: #c98500;
47
+ --chart-5: #d55181;
48
+ --chart-6: #008300;
49
+ }
50
+
51
+ /* Output and diffs take the terminal's colors when the app loads `@adecore/terminal/terminal.css`,
52
+ and the base theme's otherwise. Declared on every themed element, so a nested theme resolves its own. */
53
+ :root, [data-theme] {
54
+ --chat-code-bg: var(--term-bg, var(--surface-sunken));
55
+ --chat-output: var(--term-fg, var(--text));
56
+ --chat-added: var(--term-ansi-green, var(--positive));
57
+ --chat-removed: var(--term-ansi-red, var(--status-error));
58
+ --chart-1: var(--accent);
34
59
  }
35
60
 
36
61
  @theme inline {
@@ -44,6 +69,10 @@
44
69
  --color-chart-context-files: var(--chart-context-files);
45
70
  --color-chart-context-conversation: var(--chart-context-conversation);
46
71
  --color-chart-context-system: var(--chart-context-system);
72
+ --color-chat-find: var(--chat-find);
73
+ --color-chat-output: var(--chat-output);
74
+ --color-chat-added: var(--chat-added);
75
+ --color-chat-removed: var(--chat-removed);
47
76
  }
48
77
 
49
78
  @layer components {
@@ -98,6 +127,8 @@
98
127
  @starting-style { opacity: 0; }
99
128
  }
100
129
  @keyframes chat-fade { from { opacity: 0; } to { opacity: 1; } }
130
+ /* A visual still on its way says so only once it takes long enough to be noticed. */
131
+ .chat-visual-wait { animation: chat-fade 200ms ease-out 600ms both; }
101
132
 
102
133
  /* The row a jump from outside the chat (the card of a change in a file) landed on. */
103
134
  .chat-flash { animation: chat-flash 1600ms ease-out both; }
@@ -160,7 +191,7 @@
160
191
  .chat-markdown :is(th, td) { border: 1px solid var(--tw-prose-td-borders); padding: 4px 8px; text-align: left; }
161
192
 
162
193
  /* A code block is shiki's own `<pre>`, which brings a background of its own to override. */
163
- .chat-code { border-radius: 8px; border: 1px solid var(--border); background: var(--term-bg); overflow: auto; }
194
+ .chat-code { border-radius: 8px; border: 1px solid var(--border); background: var(--chat-code-bg); overflow: auto; }
164
195
  .chat-code pre { margin: 0; padding: round(0.625rem, 1px) round(0.75rem, 1px); background: transparent !important; font-family: var(--font-mono); font-size: var(--text-code); line-height: var(--text-code--line-height); }
165
196
  .chat-code code { font-family: inherit; }
166
197