@adecore/agents-react 0.26.3 → 0.27.1

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.
@@ -7,6 +7,7 @@ import { useChatHost } from '../../host.js';
7
7
  import { CodeBlock } from './CodeBlock.js';
8
8
  import { CodeStreamingContext } from './code-streaming.js';
9
9
  import { MERMAID_TOKENS, mermaidConfig, sizedSvg, svgDataUrl } from './mermaid.js';
10
+ import { watchTheme } from './theme-watch.js';
10
11
  // Mermaid keeps one configuration for the page, so one diagram is drawn at a time with its own.
11
12
  let queue = Promise.resolve();
12
13
  let probeCanvas = null;
@@ -32,10 +33,13 @@ function themeColors(element) {
32
33
  const entries = Object.entries(MERMAID_TOKENS).map(([name, token]) => [name, name === 'ground' ? ground : resolveColor(element, token, ground)]);
33
34
  return Object.fromEntries(entries);
34
35
  }
35
- async function drawDiagram(id, source, element, dark) {
36
- const { default: mermaid } = await import('mermaid');
36
+ function diagramLook(element) {
37
37
  const style = getComputedStyle(element);
38
- mermaid.initialize(mermaidConfig(themeColors(element), dark, { family: style.fontFamily, size: style.fontSize }));
38
+ return { colors: themeColors(element), font: { family: style.fontFamily, size: style.fontSize } };
39
+ }
40
+ async function drawDiagram(id, source, look, dark) {
41
+ const { default: mermaid } = await import('mermaid');
42
+ mermaid.initialize(mermaidConfig(look.colors, dark, look.font));
39
43
  const { svg } = await mermaid.render(id, source);
40
44
  return sizedSvg(svg);
41
45
  }
@@ -53,20 +57,38 @@ export function MermaidBlock({ code }) {
53
57
  const frame = useRef(null);
54
58
  const [source, setSource] = useState(false);
55
59
  const [drawing, setDrawing] = useState({ state: 'drawing' });
60
+ // The look of the diagram on screen or on its way there, null before the first.
61
+ const drawnLook = useRef(null);
62
+ const [themeRevision, setThemeRevision] = useState(0);
63
+ // An app may change its colors without turning light or dark, which only the root of the page tells.
64
+ useEffect(() => {
65
+ const element = frame.current;
66
+ if (element === null) {
67
+ return;
68
+ }
69
+ return watchTheme(element.ownerDocument, () => {
70
+ if (drawnLook.current !== null && JSON.stringify(diagramLook(element)) !== drawnLook.current) {
71
+ setThemeRevision((revision) => revision + 1);
72
+ }
73
+ });
74
+ }, []);
56
75
  useEffect(() => {
57
76
  const element = frame.current;
58
77
  if (streaming || element === null) {
59
78
  return;
60
79
  }
61
80
  let cancelled = false;
62
- setDrawing({ state: 'drawing' });
63
- const drawn = queue.then(() => (cancelled ? null : drawDiagram(id, code, element, dark)));
81
+ const look = diagramLook(element);
82
+ drawnLook.current = JSON.stringify(look);
83
+ // A diagram already drawn stays until the next one is there, so a change of the theme does not make it blink.
84
+ setDrawing((current) => (current.state === 'drawn' ? current : { state: 'drawing' }));
85
+ const drawn = queue.then(() => (cancelled ? null : drawDiagram(id, code, look, dark)));
64
86
  queue = drawn.catch(() => undefined);
65
87
  drawn.then((image) => !cancelled && setDrawing(image === null ? { state: 'failed' } : { state: 'drawn', image }), () => !cancelled && setDrawing({ state: 'failed' }));
66
88
  return () => {
67
89
  cancelled = true;
68
90
  };
69
- }, [code, dark, id, streaming]);
91
+ }, [code, dark, id, streaming, themeRevision]);
70
92
  const showsDiagram = !streaming && !source && drawing.state !== 'failed';
71
93
  const toggle = streaming || drawing.state === 'failed' ? undefined : (_jsx(IconButton, { icon: source ? Workflow : Code, size: "sm", label: source ? t('code.mermaid.showDiagram') : t('code.mermaid.showSource'), onClick: () => setSource((shown) => !shown) }));
72
94
  return (_jsxs("div", { ref: frame, children: [_jsx(CodeBlock, { code: code, lang: "mermaid", actions: toggle, view: showsDiagram ? (_jsx("div", { className: "overflow-x-auto p-3", "aria-busy": drawing.state === 'drawing' || undefined, children: drawing.state === 'drawn' && (_jsx("img", { src: svgDataUrl(drawing.image.svg), width: drawing.image.width, height: drawing.image.height, alt: t('code.mermaid.diagram'), className: "m-0 max-w-none", draggable: false })) })) : undefined }), drawing.state === 'failed' && _jsx("p", { className: "m-0 mt-1 text-xs text-text-faint", children: t('code.mermaid.failed') })] }));
@@ -12,7 +12,7 @@ export interface DiffSyntaxRuntime {
12
12
  }
13
13
  export interface DiffSyntaxRuntimeOptions {
14
14
  workerFactory?: () => Worker;
15
- themes: readonly ThemeRegistration[];
15
+ themes: readonly ThemeRegistration[] | (() => readonly ThemeRegistration[]);
16
16
  highlighter?: () => Promise<Highlighter>;
17
17
  }
18
18
  export declare function diffSyntaxRuntime(code: ChatHost['code']): DiffSyntaxRuntime;
@@ -5,7 +5,7 @@ const shared = new WeakMap();
5
5
  export function diffSyntaxRuntime(code) {
6
6
  let runtime = shared.get(code);
7
7
  if (runtime === undefined) {
8
- runtime = createDiffSyntaxRuntime({ workerFactory: code.syntaxWorker, themes: code.custom, highlighter: code.highlighter });
8
+ runtime = createDiffSyntaxRuntime({ workerFactory: code.syntaxWorker, themes: () => code.custom, highlighter: code.highlighter });
9
9
  shared.set(code, runtime);
10
10
  }
11
11
  return runtime;
@@ -17,6 +17,9 @@ export function createDiffSyntaxRuntime(options) {
17
17
  let highlighter;
18
18
  let started = false;
19
19
  let stopped = false;
20
+ // The names of the custom themes the current worker or fallback has, each sent once.
21
+ const sentThemes = new Set();
22
+ const themes = () => (typeof options.themes === 'function' ? options.themes() : options.themes);
20
23
  const settle = (session) => {
21
24
  for (const pending of session.pending.values()) {
22
25
  pending.resolve(null);
@@ -34,13 +37,34 @@ export function createDiffSyntaxRuntime(options) {
34
37
  highlighter ??= import('shiki').then(({ createHighlighter }) => createHighlighter({ langs: [], themes: [] }));
35
38
  return highlighter;
36
39
  };
40
+ const start = () => {
41
+ const current = themes();
42
+ sentThemes.clear();
43
+ for (const theme of current) {
44
+ if (theme.name !== undefined) {
45
+ sentThemes.add(theme.name);
46
+ }
47
+ }
48
+ dispatch({ type: 'start', themes: current, maxLines: 100000 });
49
+ };
50
+ /* Sends a custom theme the host added after the start, ahead of the first request drawn in it. */
51
+ const sendTheme = (name) => {
52
+ if (sentThemes.has(name)) {
53
+ return;
54
+ }
55
+ const theme = themes().find((candidate) => candidate.name === name);
56
+ if (theme !== undefined) {
57
+ sentThemes.add(name);
58
+ dispatch({ type: 'themes', themes: [theme] });
59
+ }
60
+ };
37
61
  const startFallback = () => {
38
62
  stopWorker();
39
63
  if (fallback !== undefined || stopped) {
40
64
  return;
41
65
  }
42
66
  fallback = createSyntaxService({ highlighter: options.highlighter ?? loadHighlighter, send: receive });
43
- dispatch({ type: 'start', themes: options.themes, maxLines: 100000 });
67
+ start();
44
68
  for (const [generation, session] of sessions) {
45
69
  session.sent = false;
46
70
  for (const index of session.pending.keys()) {
@@ -66,7 +90,7 @@ export function createDiffSyntaxRuntime(options) {
66
90
  worker.addEventListener('message', onMessage);
67
91
  worker.addEventListener('error', onError);
68
92
  worker.addEventListener('messageerror', onError);
69
- dispatch({ type: 'start', themes: options.themes, maxLines: 100000 });
93
+ start();
70
94
  return;
71
95
  }
72
96
  catch {
@@ -99,6 +123,9 @@ export function createDiffSyntaxRuntime(options) {
99
123
  };
100
124
  const request = (generation, session, index, resend = false) => {
101
125
  const document = !session.sent || resend ? session.definition : undefined;
126
+ if (document !== undefined) {
127
+ sendTheme(document.theme);
128
+ }
102
129
  session.sent = true;
103
130
  dispatch({ type: 'request', generation, index, ...(document ? { document } : {}) });
104
131
  };
@@ -1,14 +1,18 @@
1
1
  import { useMemo } from 'react';
2
2
  import { registerCustomTheme } from '@pierre/diffs';
3
3
  import { useChatHost } from '../../host.js';
4
- const registered = new WeakSet();
5
- /* The library resolves Shiki's bundled themes by id on its own; the app's own it only knows by registration, done once per host. */
4
+ // The library's registry is global and refuses a name twice, so what is registered is kept by name.
5
+ const registered = new Set();
6
+ /*
7
+ * The library resolves Shiki's bundled themes by id on its own; the app's own it only knows by registration.
8
+ * Read on every render, so a theme the host adds to `custom` later is in before a diff is drawn in it.
9
+ */
6
10
  function registerThemes(custom) {
7
- if (registered.has(custom)) {
8
- return;
9
- }
10
- registered.add(custom);
11
11
  for (const theme of custom) {
12
+ if (registered.has(theme.name)) {
13
+ continue;
14
+ }
15
+ registered.add(theme.name);
12
16
  registerCustomTheme(theme.name, () => Promise.resolve(theme));
13
17
  }
14
18
  }
@@ -6,6 +6,7 @@ import { Maximize, Minus, Plus } from 'lucide-react';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { ButtonGroup, IconButton } from '@adecore/ui';
8
8
  import { useChatHost } from '../../../../host.js';
9
+ import { watchTheme } from '../../theme-watch.js';
9
10
  import { baseMapStyle, boundsOf, localizedTextField, mapFitOptions, spreadOverlaps } from './map-style.js';
10
11
  const MARKER = 'flex size-5 items-center justify-center rounded-full bg-map-marker text-xs text-map-marker-text tabular-nums shadow-raised';
11
12
  const PICKED = 'z-1 bg-accent text-accent-text outline-2 outline-surface';
@@ -55,7 +56,7 @@ export function MapView({ markers, source, selected, onPick, onStatus }) {
55
56
  return;
56
57
  }
57
58
  const current = latest.current.source;
58
- const colors = readColors(container);
59
+ let colors = readColors(container);
59
60
  const offline = current.state === 'tiles' && typeof navigator !== 'undefined' && navigator.onLine === false;
60
61
  const tiles = current.state === 'tiles' && !offline;
61
62
  let created;
@@ -175,8 +176,21 @@ export function MapView({ markers, source, selected, onPick, onStatus }) {
175
176
  window.addEventListener('online', goOnline);
176
177
  }
177
178
  canvas.addEventListener('webglcontextlost', contextLost);
179
+ // Colors that change within the side repaint the outline in place: a style that differs only in its paint is
180
+ // applied as a diff, so the map, its camera and its markers stay. The tiles draw colors of their own.
181
+ const stopWatching = watchTheme(container.ownerDocument, () => {
182
+ const next = readColors(container);
183
+ if (next.land === colors.land && next.water === colors.water) {
184
+ return;
185
+ }
186
+ colors = next;
187
+ if (drawing === 'outline') {
188
+ created.setStyle(baseMapStyle(colors));
189
+ }
190
+ });
178
191
  setMap(created);
179
192
  return () => {
193
+ stopWatching();
180
194
  window.removeEventListener('offline', goOffline);
181
195
  window.removeEventListener('online', goOnline);
182
196
  canvas.removeEventListener('webglcontextlost', contextLost);
@@ -0,0 +1 @@
1
+ export declare function watchTheme(owner: Document, changed: () => void): () => void;
@@ -0,0 +1,36 @@
1
+ /* The attributes of the root and the body an app switches its theme, its accent or its palette with. */
2
+ const THEME_ATTRIBUTES = ['class', 'style', 'data-theme'];
3
+ /*
4
+ * Calls `changed` after the root of `owner` or its body changes a theme attribute, at most once a
5
+ * frame, for whatever resolves the theme's tokens into colors of its own. A stylesheet that changes
6
+ * changes no attribute, so an app that rewrites its palette that way touches the root's `style` as
7
+ * well, the signal the terminal follows too. Returns what stops it.
8
+ *
9
+ * Any change of those attributes counts, also one that leaves every token as it was (a dialog that
10
+ * locks the scroll writes the root's `style`), so a caller compares what it reads with what it drew.
11
+ */
12
+ export function watchTheme(owner, changed) {
13
+ const view = owner.defaultView ?? window;
14
+ let frame = 0;
15
+ const schedule = () => {
16
+ if (frame === 0) {
17
+ frame = view.requestAnimationFrame(() => {
18
+ frame = 0;
19
+ changed();
20
+ });
21
+ }
22
+ };
23
+ const observer = new view.MutationObserver(schedule);
24
+ for (const root of [owner.documentElement, owner.body]) {
25
+ if (root !== null) {
26
+ observer.observe(root, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
27
+ }
28
+ }
29
+ return () => {
30
+ observer.disconnect();
31
+ if (frame !== 0) {
32
+ view.cancelAnimationFrame(frame);
33
+ frame = 0;
34
+ }
35
+ };
36
+ }
@@ -1,6 +1,7 @@
1
1
  import { useLayoutEffect, useState } from 'react';
2
2
  import { cleanVisualVariables } from '@adecore/agent-contracts/visual';
3
3
  import { useChatHost } from '../../host.js';
4
+ import { watchTheme } from './theme-watch.js';
4
5
  /*
5
6
  * A page's theme from the app's tokens. The names follow common component themes, where `--accent`
6
7
  * is a quiet hover ground and `--primary` the color that stands out, so the app's accent is the
@@ -134,8 +135,6 @@ function readTheme(element, mode) {
134
135
  probe.remove();
135
136
  }
136
137
  }
137
- /* The attributes an app switches its theme or its accent with. */
138
- const THEME_ATTRIBUTES = ['class', 'style', 'data-theme'];
139
138
  /*
140
139
  * The app's theme for a visual drawn in `ref`, null until the element is there. It is read again when
141
140
  * the app turns light or dark, and when the root of the document changes a theme attribute, so an
@@ -156,16 +155,11 @@ export function useVisualTheme(ref) {
156
155
  };
157
156
  read();
158
157
  const owner = element.ownerDocument;
159
- const observer = new MutationObserver(read);
160
- for (const root of [owner.documentElement, owner.body]) {
161
- if (root !== null) {
162
- observer.observe(root, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
163
- }
164
- }
158
+ const stopWatching = watchTheme(owner, read);
165
159
  const scheme = owner.defaultView?.matchMedia?.('(prefers-color-scheme: dark)') ?? null;
166
160
  scheme?.addEventListener('change', read);
167
161
  return () => {
168
- observer.disconnect();
162
+ stopWatching();
169
163
  scheme?.removeEventListener('change', read);
170
164
  };
171
165
  }, [ref, mode]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.26.3",
3
+ "version": "0.27.1",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.26.3",
71
- "@adecore/editor": "0.26.3",
72
- "@adecore/editor-react": "0.26.3",
73
- "@adecore/intelligent-ui": "0.26.3",
74
- "@adecore/merge": "0.26.3",
70
+ "@adecore/agent-contracts": "0.27.1",
71
+ "@adecore/editor": "0.27.1",
72
+ "@adecore/editor-react": "0.27.1",
73
+ "@adecore/intelligent-ui": "0.27.1",
74
+ "@adecore/merge": "0.27.1",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.26.3",
97
+ "@adecore/ui": "^0.27.1",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.26.3",
104
+ "@adecore/ui": "0.27.1",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -6,6 +6,7 @@ import { useChatHost } from '../../host';
6
6
  import { CodeBlock } from './CodeBlock';
7
7
  import { CodeStreamingContext } from './code-streaming';
8
8
  import { MERMAID_TOKENS, mermaidConfig, sizedSvg, svgDataUrl, type MermaidColors, type SizedSvg } from './mermaid';
9
+ import { watchTheme } from './theme-watch';
9
10
 
10
11
  type Drawing = { state: 'drawing' } | { state: 'drawn'; image: SizedSvg } | { state: 'failed' };
11
12
 
@@ -38,10 +39,20 @@ function themeColors(element: HTMLElement): MermaidColors {
38
39
  return Object.fromEntries(entries) as unknown as MermaidColors;
39
40
  }
40
41
 
41
- async function drawDiagram(id: string, source: string, element: HTMLElement, dark: boolean): Promise<SizedSvg | null> {
42
- const { default: mermaid } = await import('mermaid');
42
+ /* What a diagram reads of the theme where it stands. */
43
+ interface DiagramLook {
44
+ colors: MermaidColors;
45
+ font: { family: string; size: string };
46
+ }
47
+
48
+ function diagramLook(element: HTMLElement): DiagramLook {
43
49
  const style = getComputedStyle(element);
44
- mermaid.initialize(mermaidConfig(themeColors(element), dark, { family: style.fontFamily, size: style.fontSize }));
50
+ return { colors: themeColors(element), font: { family: style.fontFamily, size: style.fontSize } };
51
+ }
52
+
53
+ async function drawDiagram(id: string, source: string, look: DiagramLook, dark: boolean): Promise<SizedSvg | null> {
54
+ const { default: mermaid } = await import('mermaid');
55
+ mermaid.initialize(mermaidConfig(look.colors, dark, look.font));
45
56
  const { svg } = await mermaid.render(id, source);
46
57
  return sizedSvg(svg);
47
58
  }
@@ -60,6 +71,22 @@ export function MermaidBlock({ code }: { code: string }) {
60
71
  const frame = useRef<HTMLDivElement>(null);
61
72
  const [source, setSource] = useState(false);
62
73
  const [drawing, setDrawing] = useState<Drawing>({ state: 'drawing' });
74
+ // The look of the diagram on screen or on its way there, null before the first.
75
+ const drawnLook = useRef<string | null>(null);
76
+ const [themeRevision, setThemeRevision] = useState(0);
77
+
78
+ // An app may change its colors without turning light or dark, which only the root of the page tells.
79
+ useEffect(() => {
80
+ const element = frame.current;
81
+ if (element === null) {
82
+ return;
83
+ }
84
+ return watchTheme(element.ownerDocument, () => {
85
+ if (drawnLook.current !== null && JSON.stringify(diagramLook(element)) !== drawnLook.current) {
86
+ setThemeRevision((revision) => revision + 1);
87
+ }
88
+ });
89
+ }, []);
63
90
 
64
91
  useEffect(() => {
65
92
  const element = frame.current;
@@ -67,8 +94,11 @@ export function MermaidBlock({ code }: { code: string }) {
67
94
  return;
68
95
  }
69
96
  let cancelled = false;
70
- setDrawing({ state: 'drawing' });
71
- const drawn = queue.then(() => (cancelled ? null : drawDiagram(id, code, element, dark)));
97
+ const look = diagramLook(element);
98
+ drawnLook.current = JSON.stringify(look);
99
+ // A diagram already drawn stays until the next one is there, so a change of the theme does not make it blink.
100
+ setDrawing((current) => (current.state === 'drawn' ? current : { state: 'drawing' }));
101
+ const drawn = queue.then(() => (cancelled ? null : drawDiagram(id, code, look, dark)));
72
102
  queue = drawn.catch(() => undefined);
73
103
  drawn.then(
74
104
  (image) => !cancelled && setDrawing(image === null ? { state: 'failed' } : { state: 'drawn', image }),
@@ -77,7 +107,7 @@ export function MermaidBlock({ code }: { code: string }) {
77
107
  return () => {
78
108
  cancelled = true;
79
109
  };
80
- }, [code, dark, id, streaming]);
110
+ }, [code, dark, id, streaming, themeRevision]);
81
111
 
82
112
  const showsDiagram = !streaming && !source && drawing.state !== 'failed';
83
113
  const toggle =
@@ -34,7 +34,8 @@ export interface DiffSyntaxRuntime {
34
34
 
35
35
  export interface DiffSyntaxRuntimeOptions {
36
36
  workerFactory?: () => Worker;
37
- themes: readonly ThemeRegistration[];
37
+ /* The custom themes, or a function read again whenever a document names a theme not yet sent, so a theme made later draws too. */
38
+ themes: readonly ThemeRegistration[] | (() => readonly ThemeRegistration[]);
38
39
  highlighter?: () => Promise<Highlighter>;
39
40
  }
40
41
 
@@ -45,7 +46,7 @@ const shared = new WeakMap<ChatHost['code'], DiffSyntaxRuntime>();
45
46
  export function diffSyntaxRuntime(code: ChatHost['code']): DiffSyntaxRuntime {
46
47
  let runtime = shared.get(code);
47
48
  if (runtime === undefined) {
48
- runtime = createDiffSyntaxRuntime({ workerFactory: code.syntaxWorker, themes: code.custom, highlighter: code.highlighter });
49
+ runtime = createDiffSyntaxRuntime({ workerFactory: code.syntaxWorker, themes: () => code.custom, highlighter: code.highlighter });
49
50
  shared.set(code, runtime);
50
51
  }
51
52
  return runtime;
@@ -58,6 +59,9 @@ export function createDiffSyntaxRuntime(options: DiffSyntaxRuntimeOptions): Diff
58
59
  let highlighter: Promise<Highlighter> | undefined;
59
60
  let started = false;
60
61
  let stopped = false;
62
+ // The names of the custom themes the current worker or fallback has, each sent once.
63
+ const sentThemes = new Set<string>();
64
+ const themes = (): readonly ThemeRegistration[] => (typeof options.themes === 'function' ? options.themes() : options.themes);
61
65
  const settle = (session: Session): void => {
62
66
  for (const pending of session.pending.values()) {
63
67
  pending.resolve(null);
@@ -75,13 +79,34 @@ export function createDiffSyntaxRuntime(options: DiffSyntaxRuntimeOptions): Diff
75
79
  highlighter ??= import('shiki').then(({ createHighlighter }) => createHighlighter({ langs: [], themes: [] }));
76
80
  return highlighter;
77
81
  };
82
+ const start = (): void => {
83
+ const current = themes();
84
+ sentThemes.clear();
85
+ for (const theme of current) {
86
+ if (theme.name !== undefined) {
87
+ sentThemes.add(theme.name);
88
+ }
89
+ }
90
+ dispatch({ type: 'start', themes: current, maxLines: 100000 });
91
+ };
92
+ /* Sends a custom theme the host added after the start, ahead of the first request drawn in it. */
93
+ const sendTheme = (name: string): void => {
94
+ if (sentThemes.has(name)) {
95
+ return;
96
+ }
97
+ const theme = themes().find((candidate) => candidate.name === name);
98
+ if (theme !== undefined) {
99
+ sentThemes.add(name);
100
+ dispatch({ type: 'themes', themes: [theme] });
101
+ }
102
+ };
78
103
  const startFallback = (): void => {
79
104
  stopWorker();
80
105
  if (fallback !== undefined || stopped) {
81
106
  return;
82
107
  }
83
108
  fallback = createSyntaxService({ highlighter: options.highlighter ?? loadHighlighter, send: receive });
84
- dispatch({ type: 'start', themes: options.themes, maxLines: 100000 });
109
+ start();
85
110
  for (const [generation, session] of sessions) {
86
111
  session.sent = false;
87
112
  for (const index of session.pending.keys()) {
@@ -107,7 +132,7 @@ export function createDiffSyntaxRuntime(options: DiffSyntaxRuntimeOptions): Diff
107
132
  worker.addEventListener('message', onMessage);
108
133
  worker.addEventListener('error', onError);
109
134
  worker.addEventListener('messageerror', onError);
110
- dispatch({ type: 'start', themes: options.themes, maxLines: 100000 });
135
+ start();
111
136
  return;
112
137
  } catch {
113
138
  startFallback();
@@ -136,6 +161,9 @@ export function createDiffSyntaxRuntime(options: DiffSyntaxRuntimeOptions): Diff
136
161
  };
137
162
  const request = (generation: number, session: Session, index: number, resend = false): void => {
138
163
  const document = !session.sent || resend ? session.definition : undefined;
164
+ if (document !== undefined) {
165
+ sendTheme(document.theme);
166
+ }
139
167
  session.sent = true;
140
168
  dispatch({ type: 'request', generation, index, ...(document ? { document } : {}) });
141
169
  };
@@ -2,15 +2,19 @@ import { useMemo } from 'react';
2
2
  import { registerCustomTheme, type ThemesType } from '@pierre/diffs';
3
3
  import { useChatHost, type ChatHost } from '../../host';
4
4
 
5
- const registered = new WeakSet<ChatHost['code']['custom']>();
5
+ // The library's registry is global and refuses a name twice, so what is registered is kept by name.
6
+ const registered = new Set<string>();
6
7
 
7
- /* The library resolves Shiki's bundled themes by id on its own; the app's own it only knows by registration, done once per host. */
8
+ /*
9
+ * The library resolves Shiki's bundled themes by id on its own; the app's own it only knows by registration.
10
+ * Read on every render, so a theme the host adds to `custom` later is in before a diff is drawn in it.
11
+ */
8
12
  function registerThemes(custom: ChatHost['code']['custom']): void {
9
- if (registered.has(custom)) {
10
- return;
11
- }
12
- registered.add(custom);
13
13
  for (const theme of custom) {
14
+ if (registered.has(theme.name)) {
15
+ continue;
16
+ }
17
+ registered.add(theme.name);
14
18
  registerCustomTheme(theme.name, () => Promise.resolve(theme));
15
19
  }
16
20
  }
@@ -5,6 +5,7 @@ import { Maximize, Minus, Plus } from 'lucide-react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { ButtonGroup, IconButton } from '@adecore/ui';
7
7
  import { useChatHost, type UiMapSource } from '../../../../host';
8
+ import { watchTheme } from '../../theme-watch';
8
9
  import { baseMapStyle, boundsOf, localizedTextField, mapFitOptions, spreadOverlaps, type MapColors, type MapPoint } from './map-style';
9
10
 
10
11
  /*
@@ -81,7 +82,7 @@ export function MapView({ markers, source, selected, onPick, onStatus }: MapView
81
82
  return;
82
83
  }
83
84
  const current = latest.current.source;
84
- const colors = readColors(container);
85
+ let colors = readColors(container);
85
86
  const offline = current.state === 'tiles' && typeof navigator !== 'undefined' && navigator.onLine === false;
86
87
  const tiles = current.state === 'tiles' && !offline;
87
88
  let created: MapLibreMap;
@@ -202,8 +203,21 @@ export function MapView({ markers, source, selected, onPick, onStatus }: MapView
202
203
  window.addEventListener('online', goOnline);
203
204
  }
204
205
  canvas.addEventListener('webglcontextlost', contextLost);
206
+ // Colors that change within the side repaint the outline in place: a style that differs only in its paint is
207
+ // applied as a diff, so the map, its camera and its markers stay. The tiles draw colors of their own.
208
+ const stopWatching = watchTheme(container.ownerDocument, () => {
209
+ const next = readColors(container);
210
+ if (next.land === colors.land && next.water === colors.water) {
211
+ return;
212
+ }
213
+ colors = next;
214
+ if (drawing === 'outline') {
215
+ created.setStyle(baseMapStyle(colors));
216
+ }
217
+ });
205
218
  setMap(created);
206
219
  return () => {
220
+ stopWatching();
207
221
  window.removeEventListener('offline', goOffline);
208
222
  window.removeEventListener('online', goOnline);
209
223
  canvas.removeEventListener('webglcontextlost', contextLost);
@@ -0,0 +1,37 @@
1
+ /* The attributes of the root and the body an app switches its theme, its accent or its palette with. */
2
+ const THEME_ATTRIBUTES = ['class', 'style', 'data-theme'];
3
+
4
+ /*
5
+ * Calls `changed` after the root of `owner` or its body changes a theme attribute, at most once a
6
+ * frame, for whatever resolves the theme's tokens into colors of its own. A stylesheet that changes
7
+ * changes no attribute, so an app that rewrites its palette that way touches the root's `style` as
8
+ * well, the signal the terminal follows too. Returns what stops it.
9
+ *
10
+ * Any change of those attributes counts, also one that leaves every token as it was (a dialog that
11
+ * locks the scroll writes the root's `style`), so a caller compares what it reads with what it drew.
12
+ */
13
+ export function watchTheme(owner: Document, changed: () => void): () => void {
14
+ const view = owner.defaultView ?? window;
15
+ let frame = 0;
16
+ const schedule = (): void => {
17
+ if (frame === 0) {
18
+ frame = view.requestAnimationFrame(() => {
19
+ frame = 0;
20
+ changed();
21
+ });
22
+ }
23
+ };
24
+ const observer = new view.MutationObserver(schedule);
25
+ for (const root of [owner.documentElement, owner.body]) {
26
+ if (root !== null) {
27
+ observer.observe(root, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
28
+ }
29
+ }
30
+ return () => {
31
+ observer.disconnect();
32
+ if (frame !== 0) {
33
+ view.cancelAnimationFrame(frame);
34
+ frame = 0;
35
+ }
36
+ };
37
+ }
@@ -1,6 +1,7 @@
1
1
  import { useLayoutEffect, useState, type RefObject } from 'react';
2
2
  import { cleanVisualVariables, type VisualAppearance, type VisualTheme, type VisualThemeVariable } from '@adecore/agent-contracts/visual';
3
3
  import { useChatHost } from '../../host';
4
+ import { watchTheme } from './theme-watch';
4
5
 
5
6
  /* The tokens of the app's theme where a frame stands; each answers '' for a token the theme lacks. */
6
7
  export interface VisualTokens {
@@ -158,9 +159,6 @@ function readTheme(element: HTMLElement, mode: VisualAppearance): VisualTheme {
158
159
  }
159
160
  }
160
161
 
161
- /* The attributes an app switches its theme or its accent with. */
162
- const THEME_ATTRIBUTES = ['class', 'style', 'data-theme'];
163
-
164
162
  /*
165
163
  * The app's theme for a visual drawn in `ref`, null until the element is there. It is read again when
166
164
  * the app turns light or dark, and when the root of the document changes a theme attribute, so an
@@ -181,16 +179,11 @@ export function useVisualTheme(ref: RefObject<HTMLElement | null>): VisualTheme
181
179
  };
182
180
  read();
183
181
  const owner = element.ownerDocument;
184
- const observer = new MutationObserver(read);
185
- for (const root of [owner.documentElement, owner.body]) {
186
- if (root !== null) {
187
- observer.observe(root, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
188
- }
189
- }
182
+ const stopWatching = watchTheme(owner, read);
190
183
  const scheme = owner.defaultView?.matchMedia?.('(prefers-color-scheme: dark)') ?? null;
191
184
  scheme?.addEventListener('change', read);
192
185
  return () => {
193
- observer.disconnect();
186
+ stopWatching();
194
187
  scheme?.removeEventListener('change', read);
195
188
  };
196
189
  }, [ref, mode]);
package/src/host.ts CHANGED
@@ -319,7 +319,10 @@ export interface ChatHost {
319
319
  useMode(): 'light' | 'dark';
320
320
  /* The Shiki theme ids code is drawn in, one per mode. */
321
321
  useThemes(): { light: string; dark: string };
322
- /* Themes the app ships itself, found by name; the rest Shiki looks up by id. */
322
+ /*
323
+ * Themes the app ships itself, found by name; the rest Shiki looks up by id. Read again whenever a theme is
324
+ * picked, so a getter that answers a longer array draws a theme made later; a name keeps its first theme.
325
+ */
323
326
  custom: ReadonlyArray<ThemeRegistration & { name: string }>;
324
327
  /* One syntax worker shared by chat diffs and standalone review panes; omitted, tokenize on the page. */
325
328
  syntaxWorker?: () => Worker;