@adecore/agents-react 0.17.0-beta.2 → 0.18.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 (49) hide show
  1. package/dist/chat/chat-client.d.ts +2 -1
  2. package/dist/chat/chat-client.js +8 -2
  3. package/dist/chat/logic/fork.js +2 -0
  4. package/dist/chat/logic/thread-cards.d.ts +9 -0
  5. package/dist/chat/logic/thread-cards.js +35 -13
  6. package/dist/chat/logic/timeline.d.ts +5 -1
  7. package/dist/chat/logic/timeline.js +1 -1
  8. package/dist/chat/logic/visual-height.d.ts +4 -0
  9. package/dist/chat/logic/visual-height.js +32 -0
  10. package/dist/chat/ui/Timeline.js +8 -3
  11. package/dist/chat/ui/VisualDialogs.d.ts +3 -0
  12. package/dist/chat/ui/VisualDialogs.js +32 -0
  13. package/dist/chat/ui/VisualFrame.d.ts +8 -0
  14. package/dist/chat/ui/VisualFrame.js +95 -0
  15. package/dist/chat/ui/rows/Rows.js +3 -0
  16. package/dist/chat/ui/rows/VisualRow.d.ts +5 -0
  17. package/dist/chat/ui/rows/VisualRow.js +23 -0
  18. package/dist/chat/ui/visual-theme.d.ts +10 -0
  19. package/dist/chat/ui/visual-theme.js +172 -0
  20. package/dist/chat/visual-bridge.d.ts +37 -0
  21. package/dist/chat/visual-bridge.js +129 -0
  22. package/dist/chat/visuals.d.ts +9 -0
  23. package/dist/chat/visuals.js +8 -0
  24. package/dist/host.d.ts +5 -0
  25. package/dist/host.js +2 -1
  26. package/dist/locales/en/agent-chat.json +12 -0
  27. package/dist/locales/nl/agent-chat.json +12 -0
  28. package/dist/state/chats.d.ts +4 -1
  29. package/dist/state/chats.js +14 -4
  30. package/dist/theme.css +15 -0
  31. package/package.json +38 -3
  32. package/src/chat/chat-client.ts +10 -1
  33. package/src/chat/logic/fork.ts +2 -0
  34. package/src/chat/logic/thread-cards.ts +46 -13
  35. package/src/chat/logic/timeline.ts +5 -2
  36. package/src/chat/logic/visual-height.ts +38 -0
  37. package/src/chat/ui/Timeline.tsx +11 -2
  38. package/src/chat/ui/VisualDialogs.tsx +64 -0
  39. package/src/chat/ui/VisualFrame.tsx +136 -0
  40. package/src/chat/ui/rows/Rows.tsx +3 -0
  41. package/src/chat/ui/rows/VisualRow.tsx +47 -0
  42. package/src/chat/ui/visual-theme.ts +197 -0
  43. package/src/chat/visual-bridge.ts +176 -0
  44. package/src/chat/visuals.ts +22 -0
  45. package/src/host.ts +12 -1
  46. package/src/locales/en/agent-chat.json +12 -0
  47. package/src/locales/nl/agent-chat.json +12 -0
  48. package/src/state/chats.ts +19 -5
  49. package/src/theme.css +15 -0
@@ -0,0 +1,197 @@
1
+ import { useLayoutEffect, useState, type RefObject } from 'react';
2
+ import { cleanVisualVariables, type VisualAppearance, type VisualTheme, type VisualThemeVariable } from '@adecore/agent-contracts/visual';
3
+ import { chatHost } from '../../host';
4
+
5
+ /* The tokens of the app's theme where a frame stands; each answers '' for a token the theme lacks. */
6
+ export interface VisualTokens {
7
+ /* As the engine computes the color, which a page's script parses where it would not parse a `color-mix()`. */
8
+ color(token: string): string;
9
+ /* In pixels, where the theme may write a length as a calculation. */
10
+ length(token: string): string;
11
+ /* As it is declared, such as a font stack. */
12
+ value(token: string): string;
13
+ }
14
+
15
+ /*
16
+ * A page's theme from the app's tokens. The names follow common component themes, where `--accent`
17
+ * is a quiet hover ground and `--primary` the color that stands out, so the app's accent is the
18
+ * primary, the ring and the first series of a chart. A variable without a token here, or with a
19
+ * token the app does not set, keeps the page's default for this appearance.
20
+ */
21
+ export function visualThemeOf(tokens: VisualTokens, appearance: VisualAppearance, background: string): VisualTheme {
22
+ const text = tokens.color('--text');
23
+ const quiet = tokens.color('--surface-hover');
24
+ const raised = tokens.color('--surface-raised');
25
+ const accent = tokens.color('--accent');
26
+ const onAccent = tokens.color('--accent-text');
27
+ const variables: Partial<Record<VisualThemeVariable, string>> = {
28
+ '--background': background,
29
+ '--foreground': text,
30
+ '--muted': quiet,
31
+ '--muted-foreground': tokens.color('--text-muted'),
32
+ '--card': raised,
33
+ '--card-foreground': text,
34
+ '--popover': raised,
35
+ '--popover-foreground': text,
36
+ '--secondary': quiet,
37
+ '--secondary-foreground': text,
38
+ '--border': tokens.color('--border'),
39
+ '--input': tokens.color('--border-strong'),
40
+ '--ring': accent,
41
+ '--primary': accent,
42
+ '--primary-foreground': onAccent,
43
+ '--accent': quiet,
44
+ '--accent-foreground': text,
45
+ '--destructive': tokens.color('--status-error'),
46
+ '--destructive-foreground': onAccent,
47
+ '--warning': tokens.color('--status-needs-you'),
48
+ '--success': tokens.color('--positive'),
49
+ '--success-foreground': tokens.color('--positive-text'),
50
+ '--info': tokens.color('--status-running'),
51
+ '--code-background': tokens.color('--chat-code-bg'),
52
+ '--code-foreground': tokens.color('--chat-output'),
53
+ '--chart-1': tokens.color('--chart-1') || accent,
54
+ '--chart-2': tokens.color('--chart-2'),
55
+ '--chart-3': tokens.color('--chart-3'),
56
+ '--chart-4': tokens.color('--chart-4'),
57
+ '--chart-5': tokens.color('--chart-5'),
58
+ '--chart-6': tokens.color('--chart-6'),
59
+ '--radius': tokens.length('--radius-lg'),
60
+ '--font-sans': tokens.value('--font-sans'),
61
+ '--font-mono': tokens.value('--font-mono')
62
+ };
63
+ return { appearance, variables: cleanVisualVariables(variables) };
64
+ }
65
+
66
+ const RGB = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/;
67
+
68
+ interface Rgba {
69
+ red: number;
70
+ green: number;
71
+ blue: number;
72
+ alpha: number;
73
+ }
74
+
75
+ function rgbaOf(color: string): Rgba | null {
76
+ const match = RGB.exec(color.trim());
77
+ if (match === null) {
78
+ return null;
79
+ }
80
+ const alpha = match[4] === undefined ? 1 : Number(match[4]) / (match[5] === '%' ? 100 : 1);
81
+ return { red: Number(match[1]), green: Number(match[2]), blue: Number(match[3]), alpha };
82
+ }
83
+
84
+ function isTransparent(color: string): boolean {
85
+ return color === 'transparent' || rgbaOf(color)?.alpha === 0;
86
+ }
87
+
88
+ /*
89
+ * What lies behind an element, from the computed backgrounds of it and its ancestors, nearest first:
90
+ * the first one that covers what is under it, with every translucent one in front of it laid over it.
91
+ * Null when none of them paints.
92
+ */
93
+ export function backgroundBehind(layers: readonly string[]): string | null {
94
+ const translucent: Rgba[] = [];
95
+ for (const layer of layers) {
96
+ if (layer === '' || isTransparent(layer)) {
97
+ continue;
98
+ }
99
+ const color = rgbaOf(layer);
100
+ if (color !== null && color.alpha < 1) {
101
+ translucent.push(color);
102
+ continue;
103
+ }
104
+ if (color === null || translucent.length === 0) {
105
+ // A color in a space other than sRGB stays as the engine wrote it, since there is no laying another over it here.
106
+ return color === null ? layer : `rgb(${color.red}, ${color.green}, ${color.blue})`;
107
+ }
108
+ let { red, green, blue } = color;
109
+ for (const over of translucent.reverse()) {
110
+ red = over.red * over.alpha + red * (1 - over.alpha);
111
+ green = over.green * over.alpha + green * (1 - over.alpha);
112
+ blue = over.blue * over.alpha + blue * (1 - over.alpha);
113
+ }
114
+ return `rgb(${Math.round(red)}, ${Math.round(green)}, ${Math.round(blue)})`;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function sameTheme(left: VisualTheme, right: VisualTheme): boolean {
120
+ return left.appearance === right.appearance && JSON.stringify(left.variables) === JSON.stringify(right.variables);
121
+ }
122
+
123
+ /*
124
+ * The theme where `element` stands. Colors and lengths are read off a probe in that very place, so a
125
+ * nested theme answers for itself and every value comes out the way the engine computes it.
126
+ */
127
+ function readTheme(element: HTMLElement, mode: VisualAppearance): VisualTheme {
128
+ const view = element.ownerDocument.defaultView ?? window;
129
+ const style = view.getComputedStyle(element);
130
+ const probe = element.ownerDocument.createElement('span');
131
+ probe.style.position = 'absolute';
132
+ probe.style.visibility = 'hidden';
133
+ element.append(probe);
134
+ try {
135
+ const probed = view.getComputedStyle(probe);
136
+ const declared = (token: string): string => style.getPropertyValue(token).trim();
137
+ const computed = (property: 'color' | 'borderTopLeftRadius', token: string): string => {
138
+ if (declared(token) === '') {
139
+ return '';
140
+ }
141
+ probe.style[property] = `var(${token})`;
142
+ return probed[property];
143
+ };
144
+ const tokens: VisualTokens = {
145
+ color: (token) => computed('color', token),
146
+ length: (token) => computed('borderTopLeftRadius', token),
147
+ value: declared
148
+ };
149
+ const layers: string[] = [];
150
+ for (let node: Element | null = element; node !== null; node = node.parentElement) {
151
+ layers.push(view.getComputedStyle(node).backgroundColor);
152
+ }
153
+ const scheme = style.colorScheme.trim();
154
+ const appearance = scheme === 'light' || scheme === 'dark' ? scheme : mode;
155
+ return visualThemeOf(tokens, appearance, backgroundBehind(layers) ?? tokens.color('--surface'));
156
+ } finally {
157
+ probe.remove();
158
+ }
159
+ }
160
+
161
+ /* The attributes an app switches its theme or its accent with. */
162
+ const THEME_ATTRIBUTES = ['class', 'style', 'data-theme'];
163
+
164
+ /*
165
+ * The app's theme for a visual drawn in `ref`, null until the element is there. It is read again when
166
+ * the app turns light or dark, and when the root of the document changes a theme attribute, so an
167
+ * accent or a font a person picks reaches every frame as well.
168
+ */
169
+ export function useVisualTheme(ref: RefObject<HTMLElement | null>): VisualTheme | null {
170
+ const mode = chatHost().code.useMode();
171
+ const [theme, setTheme] = useState<VisualTheme | null>(null);
172
+ useLayoutEffect(() => {
173
+ const element = ref.current;
174
+ if (element === null) {
175
+ return;
176
+ }
177
+ const read = (): void => {
178
+ const next = readTheme(element, mode);
179
+ setTheme((current) => (current !== null && sameTheme(current, next) ? current : next));
180
+ };
181
+ read();
182
+ const owner = element.ownerDocument;
183
+ const observer = new MutationObserver(read);
184
+ for (const root of [owner.documentElement, owner.body]) {
185
+ if (root !== null) {
186
+ observer.observe(root, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
187
+ }
188
+ }
189
+ const scheme = owner.defaultView?.matchMedia?.('(prefers-color-scheme: dark)') ?? null;
190
+ scheme?.addEventListener('change', read);
191
+ return () => {
192
+ observer.disconnect();
193
+ scheme?.removeEventListener('change', read);
194
+ };
195
+ }, [ref, mode]);
196
+ return theme;
197
+ }
@@ -0,0 +1,176 @@
1
+ import {
2
+ VISUAL_BRIDGE_METHODS,
3
+ parseVisualMessage,
4
+ visualHostContextMessage,
5
+ visualOpenLinkResult,
6
+ visualResourceReadyMessage,
7
+ type ChatVisual,
8
+ type VisualAppearance,
9
+ type VisualTheme
10
+ } from '@adecore/agent-contracts/visual';
11
+ import { clampVisualHeight } from './logic/visual-height';
12
+
13
+ /*
14
+ * How long a frame that finished loading may stay silent before it counts as failed. The sandbox host
15
+ * page speaks while it loads, so silence means the address served something else; the margin is for
16
+ * its message and the load event, which cross between processes and may arrive in either order.
17
+ */
18
+ const SILENCE_MS = 3000;
19
+
20
+ /* The window of a visual's frame, as the bridge posts into it. */
21
+ export interface VisualFrameWindow {
22
+ postMessage(message: unknown, targetOrigin: string): void;
23
+ }
24
+
25
+ export interface VisualBridgeOptions {
26
+ /* The frame's window as it is now, or null; read for every message, since only that window may speak for the frame. */
27
+ frame(): VisualFrameWindow | null;
28
+ visual: Pick<ChatVisual, 'maxHeight'>;
29
+ /* The page, as text. It goes to the frame every time the sandbox host page says it listens. */
30
+ page: Promise<string>;
31
+ /* The theme the frame's address carries. */
32
+ theme: VisualTheme;
33
+ /* Whether a person is acting in the frame right now, so a link the page asks to open is one they followed. */
34
+ mayOpenLink(): boolean;
35
+ openLink(url: string): void;
36
+ onHeight(height: number): void;
37
+ /* The appearance the page is drawn in, from the first size it reports and after every theme it gets since. */
38
+ onAppearance(appearance: VisualAppearance): void;
39
+ /* The page could not be read, or the frame's address answered with something other than the sandbox host page. */
40
+ onFailure(): void;
41
+ /* Runs `run` after `ms`, and answers what cancels it; a test hands a clock of its own. */
42
+ after?(ms: number, run: () => void): () => void;
43
+ }
44
+
45
+ function timeout(ms: number, run: () => void): () => void {
46
+ const timer = setTimeout(run, ms);
47
+ return () => clearTimeout(timer);
48
+ }
49
+
50
+ /*
51
+ * Talks to the frame of one visual. It hands the sandbox host page the visual's page when it says it
52
+ * listens, keeps the page in the app's theme, follows the size the page reports and opens the links a
53
+ * person follows in it. A message counts only when it comes from the frame's own window, and the
54
+ * messages that come from the page count only once the page was sent.
55
+ */
56
+ export class VisualBridge {
57
+ private readonly options: VisualBridgeOptions;
58
+ private theme: VisualTheme;
59
+ private html: string | null = null;
60
+ /* Whether the sandbox host page ever said it listens, which is what tells it from an error page. */
61
+ private heard = false;
62
+ /* The sandbox host page listens and has not been sent the page yet. */
63
+ private waiting = false;
64
+ /* The theme the page was last sent, from the moment it was sent; null before that. */
65
+ private sent: VisualTheme | null = null;
66
+ /* Whether the page reported a size since it was sent, which says it runs and is drawn. */
67
+ private live = false;
68
+ private disposed = false;
69
+ private cancelSilence: () => void = () => undefined;
70
+
71
+ constructor(options: VisualBridgeOptions) {
72
+ this.options = options;
73
+ this.theme = options.theme;
74
+ options.page.then(
75
+ (html) => {
76
+ this.html = html;
77
+ this.sendPage();
78
+ },
79
+ () => {
80
+ if (!this.disposed) {
81
+ options.onFailure();
82
+ }
83
+ }
84
+ );
85
+ }
86
+
87
+ /* A `message` event of the window the frame is in. */
88
+ receive(event: { source: unknown; data: unknown }): void {
89
+ const frame = this.options.frame();
90
+ if (this.disposed || frame === null || event.source !== frame) {
91
+ return;
92
+ }
93
+ const message = parseVisualMessage(event.data);
94
+ switch (message?.method) {
95
+ case VISUAL_BRIDGE_METHODS.sandboxProxyReady:
96
+ // A frame that loads again starts over from the sandbox host page.
97
+ this.heard = true;
98
+ this.waiting = true;
99
+ this.sent = null;
100
+ this.live = false;
101
+ this.cancelSilence();
102
+ this.sendPage();
103
+ return;
104
+ case VISUAL_BRIDGE_METHODS.sizeChanged:
105
+ if (this.sent === null) {
106
+ return;
107
+ }
108
+ if (!this.live) {
109
+ this.live = true;
110
+ this.options.onAppearance(this.sent.appearance);
111
+ }
112
+ this.options.onHeight(clampVisualHeight(this.options.visual, message.height));
113
+ return;
114
+ case VISUAL_BRIDGE_METHODS.openLink:
115
+ if (this.sent === null) {
116
+ return;
117
+ }
118
+ if (this.options.mayOpenLink()) {
119
+ this.options.openLink(message.url);
120
+ }
121
+ frame.postMessage(visualOpenLinkResult(message.id), '*');
122
+ return;
123
+ default:
124
+ return;
125
+ }
126
+ }
127
+
128
+ /* The app's theme changed; a page that was sent follows it at once, without loading again. */
129
+ setTheme(theme: VisualTheme): void {
130
+ this.theme = theme;
131
+ const frame = this.options.frame();
132
+ if (this.disposed || this.sent === null || frame === null) {
133
+ return;
134
+ }
135
+ this.postTheme(frame);
136
+ if (this.live) {
137
+ this.options.onAppearance(theme.appearance);
138
+ }
139
+ }
140
+
141
+ /* The frame fired `load`. */
142
+ loaded(): void {
143
+ if (this.heard || this.disposed) {
144
+ return;
145
+ }
146
+ this.cancelSilence();
147
+ this.cancelSilence = (this.options.after ?? timeout)(SILENCE_MS, () => {
148
+ if (!this.heard && !this.disposed) {
149
+ this.options.onFailure();
150
+ }
151
+ });
152
+ }
153
+
154
+ dispose(): void {
155
+ this.disposed = true;
156
+ this.cancelSilence();
157
+ }
158
+
159
+ private sendPage(): void {
160
+ const frame = this.options.frame();
161
+ if (this.disposed || !this.waiting || this.html === null || frame === null) {
162
+ return;
163
+ }
164
+ this.waiting = false;
165
+ // Any origin, since the frame's own is opaque; `receive` checks every answer's source instead.
166
+ frame.postMessage(visualResourceReadyMessage(this.html), '*');
167
+ // The page writes itself as that message arrives and listens before the next one, so a theme
168
+ // that changed since the frame's address was set follows right after it.
169
+ this.postTheme(frame);
170
+ }
171
+
172
+ private postTheme(frame: VisualFrameWindow): void {
173
+ this.sent = this.theme;
174
+ frame.postMessage(visualHostContextMessage(this.theme), '*');
175
+ }
176
+ }
@@ -0,0 +1,22 @@
1
+ import { create } from 'zustand';
2
+
3
+ /*
4
+ * The one visual in the window that has its large view or the question before its removal up, by the
5
+ * scope's key of its chat. The thread draws both rather than the row, since the thread only keeps the
6
+ * rows in view and a row that scrolls out would take an open dialog with it.
7
+ */
8
+ interface VisualDialog {
9
+ chatKey: string | null;
10
+ visualId: string | null;
11
+ kind: 'expand' | 'remove' | null;
12
+ open(chatKey: string, visualId: string, kind: 'expand' | 'remove'): void;
13
+ close(): void;
14
+ }
15
+
16
+ export const useVisualDialog = create<VisualDialog>((set) => ({
17
+ chatKey: null,
18
+ visualId: null,
19
+ kind: null,
20
+ open: (chatKey, visualId, kind) => set({ chatKey, visualId, kind }),
21
+ close: () => set({ chatKey: null, visualId: null, kind: null })
22
+ }));
package/src/host.ts CHANGED
@@ -154,6 +154,14 @@ export interface ThreadCard {
154
154
  render(): ReactNode;
155
155
  }
156
156
 
157
+ /* Where the visuals agents publish are drawn: a sandboxed frame on an opaque origin, with no rights over the app. */
158
+ export interface VisualHost {
159
+ /* The address of the sandbox host page (`VISUAL_HOST_PAGE`), on another origin or on the app's own behind a `sandbox` policy. A frame adds the theme to it as a fragment. */
160
+ frameUrl: string;
161
+ /* Opens an http or https link a person followed in a visual, outside the app. */
162
+ openLink(url: string): void;
163
+ }
164
+
157
165
  /* Who writes a chat's replies, as the header over each one names them. */
158
166
  export interface ReplyAuthor {
159
167
  name: string;
@@ -264,6 +272,8 @@ export interface ChatHost {
264
272
  useThreadCards(scopeId: string, chatId: string): readonly ThreadCard[];
265
273
  /* A header over every reply with its author and how long ago it began; null names the agent for a screen reader only. */
266
274
  useReplyAuthor(scopeId: string, chatId: string): ReplyAuthor | null;
275
+ /* The pages agents publish, drawn in the thread above their reply; null draws none. */
276
+ visuals: VisualHost | null;
267
277
  }
268
278
 
269
279
  const NO_PROMPTS: readonly HostPrompt[] = [];
@@ -307,7 +317,8 @@ const DEFAULT_HOST: ChatHost = {
307
317
  ComposerSlot: null,
308
318
  SubagentSlot: null,
309
319
  useThreadCards: () => NO_CARDS,
310
- useReplyAuthor: () => null
320
+ useReplyAuthor: () => null,
321
+ visuals: null
311
322
  };
312
323
 
313
324
  let host: ChatHost = DEFAULT_HOST;
@@ -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,5 +1,5 @@
1
1
  import { create } from 'zustand';
2
- import type { ChatApprovalItem, ChatBookmark, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
2
+ import type { ChatApprovalItem, ChatBookmark, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem, ChatVisual } from '@adecore/agent-contracts';
3
3
  import { useChatScope } from '../scope';
4
4
 
5
5
  export interface ChatState {
@@ -22,6 +22,8 @@ export interface ChatState {
22
22
  waitingBefore?: string[];
23
23
  /* The messages marked to come back to, as the host last said; absent until it said anything. */
24
24
  bookmarks?: ChatBookmark[];
25
+ /* The pages agents published in the chat, as the host last said; absent until it said anything. */
26
+ visuals?: ChatVisual[];
25
27
  }
26
28
 
27
29
  export type ChatHistoryPage = ChatHistoryResult['history'];
@@ -49,6 +51,7 @@ export interface ChatSink {
49
51
  /* What a chat is doing, for a thread nobody in this window has open. */
50
52
  status(chatId: string, info: ChatInfo): void;
51
53
  bookmarks(chatId: string, bookmarks: ChatBookmark[]): void;
54
+ visuals(chatId: string, visuals: ChatVisual[]): void;
52
55
  forget(chatId: string): void;
53
56
  }
54
57
 
@@ -61,6 +64,7 @@ interface ChatsStore {
61
64
  apply(key: string, event: ChatEvent): void;
62
65
  status(key: string, info: ChatInfo): void;
63
66
  bookmarks(key: string, bookmarks: ChatBookmark[]): void;
67
+ visuals(key: string, visuals: ChatVisual[]): void;
64
68
  forget(key: string): void;
65
69
  /* Drops the threads whose key matches, such as every thread of one host. They keep running there; this client is done looking at them. */
66
70
  forgetWhere(matches: (key: string) => boolean): void;
@@ -182,11 +186,11 @@ export const useChats = create<ChatsStore>((set) => ({
182
186
  statusByKey: {},
183
187
  reset(key, info, items, page) {
184
188
  set((s) => {
185
- // The bookmarks are not part of the thread; they come in on their own and outlive a reset of it.
186
- const bookmarks = s.byKey[key]?.bookmarks;
187
- const next = stateOf(info, items, page);
189
+ // The bookmarks and the visuals are not part of the thread; they come in on their own and outlive a reset of it.
190
+ const { bookmarks, visuals } = s.byKey[key] ?? {};
191
+ const next = { ...stateOf(info, items, page), ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
188
192
  return {
189
- byKey: { ...s.byKey, [key]: bookmarks === undefined ? next : { ...next, bookmarks } },
193
+ byKey: { ...s.byKey, [key]: next },
190
194
  statusByKey: withStatus(s.statusByKey, key, info)
191
195
  };
192
196
  });
@@ -238,6 +242,15 @@ export const useChats = create<ChatsStore>((set) => ({
238
242
  return { byKey: { ...s.byKey, [key]: { ...current, bookmarks } } };
239
243
  });
240
244
  },
245
+ visuals(key, visuals) {
246
+ set((s) => {
247
+ const current = s.byKey[key];
248
+ if (!current) {
249
+ return {};
250
+ }
251
+ return { byKey: { ...s.byKey, [key]: { ...current, visuals } } };
252
+ });
253
+ },
241
254
  forget(key) {
242
255
  set((s) => {
243
256
  const next = { ...s.byKey };
@@ -260,6 +273,7 @@ export function chatSink(keyOf: (chatId: string) => string): ChatSink {
260
273
  apply: (chatId, event) => useChats.getState().apply(keyOf(chatId), event),
261
274
  status: (chatId, info) => useChats.getState().status(keyOf(chatId), info),
262
275
  bookmarks: (chatId, bookmarks) => useChats.getState().bookmarks(keyOf(chatId), bookmarks),
276
+ visuals: (chatId, visuals) => useChats.getState().visuals(keyOf(chatId), visuals),
263
277
  forget: (chatId) => useChats.getState().forget(keyOf(chatId))
264
278
  };
265
279
  }
package/src/theme.css CHANGED
@@ -20,6 +20,13 @@
20
20
  --chart-context-system: var(--text-faint);
21
21
  /* The current hit of a find, which has to stand out from the accent of a bookmark next to it. */
22
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;
23
30
  }
24
31
 
25
32
  [data-theme="dark"] {
@@ -34,6 +41,11 @@
34
41
  --chart-context-conversation: #6dd08b;
35
42
  --chart-context-system: var(--text-faint);
36
43
  --chat-find: #f5c542;
44
+ --chart-2: #d95926;
45
+ --chart-3: #199e70;
46
+ --chart-4: #c98500;
47
+ --chart-5: #d55181;
48
+ --chart-6: #008300;
37
49
  }
38
50
 
39
51
  /* Output and diffs take the terminal's colors when the app loads `@adecore/terminal/terminal.css`,
@@ -43,6 +55,7 @@
43
55
  --chat-output: var(--term-fg, var(--text));
44
56
  --chat-added: var(--term-ansi-green, var(--positive));
45
57
  --chat-removed: var(--term-ansi-red, var(--status-error));
58
+ --chart-1: var(--accent);
46
59
  }
47
60
 
48
61
  @theme inline {
@@ -114,6 +127,8 @@
114
127
  @starting-style { opacity: 0; }
115
128
  }
116
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; }
117
132
 
118
133
  /* The row a jump from outside the chat (the card of a change in a file) landed on. */
119
134
  .chat-flash { animation: chat-flash 1600ms ease-out both; }