@adecore/agents-react 0.26.2 → 0.27.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.
- package/dist/chat/ui/Markdown.js +33 -1
- package/dist/chat/ui/MermaidBlock.js +28 -6
- package/dist/chat/ui/diff/syntax-runtime.d.ts +1 -1
- package/dist/chat/ui/diff/syntax-runtime.js +30 -3
- package/dist/chat/ui/diff-theme.js +10 -6
- package/dist/chat/ui/intelligent-ui/renderers/map-view.js +15 -1
- package/dist/chat/ui/theme-watch.d.ts +1 -0
- package/dist/chat/ui/theme-watch.js +36 -0
- package/dist/chat/ui/visual-theme.js +3 -9
- package/dist/host.d.ts +4 -0
- package/package.json +8 -8
- package/src/chat/ui/Markdown.tsx +36 -1
- package/src/chat/ui/MermaidBlock.tsx +36 -6
- package/src/chat/ui/diff/syntax-runtime.ts +32 -4
- package/src/chat/ui/diff-theme.ts +10 -6
- package/src/chat/ui/intelligent-ui/renderers/map-view.tsx +15 -1
- package/src/chat/ui/theme-watch.ts +37 -0
- package/src/chat/ui/visual-theme.ts +3 -10
- package/src/host.ts +6 -1
package/dist/chat/ui/Markdown.js
CHANGED
|
@@ -112,6 +112,36 @@ function MarkdownLink({ href, children }) {
|
|
|
112
112
|
function ExternalLink({ href, children }) {
|
|
113
113
|
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
|
|
114
114
|
}
|
|
115
|
+
/*
|
|
116
|
+
* The path on the host's machine an image target names, or null for a URL and for a relative path
|
|
117
|
+
* nobody gave a folder for. react-markdown has already emptied a target of a scheme it distrusts.
|
|
118
|
+
*/
|
|
119
|
+
function localImagePath(src, cwd) {
|
|
120
|
+
if (src === '' || /^[a-z][a-z0-9+.-]*:/i.test(src)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const target = hrefPath(src);
|
|
124
|
+
if (target.startsWith('/')) {
|
|
125
|
+
return target;
|
|
126
|
+
}
|
|
127
|
+
if (cwd === null) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return `${cwd.replace(/\/+$/, '')}/${target.replace(/^\.\//, '')}`;
|
|
131
|
+
}
|
|
132
|
+
/* A markdown image, which only the host can read when it names a file on its machine. */
|
|
133
|
+
function MarkdownImage({ src, alt }) {
|
|
134
|
+
const { MarkdownImage: HostImage } = useChatHost();
|
|
135
|
+
const cwd = useFileLinkCwd();
|
|
136
|
+
const path = typeof src === 'string' ? localImagePath(src, cwd) : null;
|
|
137
|
+
if (HostImage === undefined || path === null) {
|
|
138
|
+
return _jsx("img", { src: src, alt: alt });
|
|
139
|
+
}
|
|
140
|
+
return _jsx(HostImage, { path: path, alt: alt ?? '' });
|
|
141
|
+
}
|
|
142
|
+
function PlainImage({ src, alt }) {
|
|
143
|
+
return _jsx("img", { src: src, alt: alt });
|
|
144
|
+
}
|
|
115
145
|
/* A ```mermaid fence, which draws what it says. */
|
|
116
146
|
function Diagram({ code }) {
|
|
117
147
|
const { t } = useTranslation('agent-chat');
|
|
@@ -135,6 +165,7 @@ const components = {
|
|
|
135
165
|
a({ href, children }) {
|
|
136
166
|
return _jsx(MarkdownLink, { href: href, children: children });
|
|
137
167
|
},
|
|
168
|
+
img: MarkdownImage,
|
|
138
169
|
table({ children }) {
|
|
139
170
|
// A table wider than the column scrolls on its own instead of pushing the thread sideways.
|
|
140
171
|
return (_jsx("div", { className: "chat-table", children: _jsx("table", { children: children }) }));
|
|
@@ -146,7 +177,8 @@ const plainComponents = {
|
|
|
146
177
|
code({ className, children }) {
|
|
147
178
|
return (_jsx(MarkdownCode, { className: className, plain: true, children: children }));
|
|
148
179
|
},
|
|
149
|
-
a: ExternalLink
|
|
180
|
+
a: ExternalLink,
|
|
181
|
+
img: PlainImage
|
|
150
182
|
};
|
|
151
183
|
// A reply sits under the heading of its message, an `h3`, so its own headings start one level below
|
|
152
184
|
// that. Only `aria-level` moves. The tag stays, since the styles and a copy of the thread read it.
|
|
@@ -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
|
-
|
|
36
|
-
const { default: mermaid } = await import('mermaid');
|
|
36
|
+
function diagramLook(element) {
|
|
37
37
|
const style = getComputedStyle(element);
|
|
38
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
162
|
+
stopWatching();
|
|
169
163
|
scheme?.removeEventListener('change', read);
|
|
170
164
|
};
|
|
171
165
|
}, [ref, mode]);
|
package/dist/host.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
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.
|
|
71
|
-
"@adecore/editor": "0.
|
|
72
|
-
"@adecore/editor-react": "0.
|
|
73
|
-
"@adecore/intelligent-ui": "0.
|
|
74
|
-
"@adecore/merge": "0.
|
|
70
|
+
"@adecore/agent-contracts": "0.27.0",
|
|
71
|
+
"@adecore/editor": "0.27.0",
|
|
72
|
+
"@adecore/editor-react": "0.27.0",
|
|
73
|
+
"@adecore/intelligent-ui": "0.27.0",
|
|
74
|
+
"@adecore/merge": "0.27.0",
|
|
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.
|
|
97
|
+
"@adecore/ui": "^0.27.0",
|
|
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.
|
|
104
|
+
"@adecore/ui": "0.27.0",
|
|
105
105
|
"@types/bun": "^1.4.2",
|
|
106
106
|
"@types/react": "^19.3.0",
|
|
107
107
|
"@types/react-dom": "^19.3.0",
|
package/src/chat/ui/Markdown.tsx
CHANGED
|
@@ -161,6 +161,39 @@ function ExternalLink({ href, children }: { href?: string; children?: ReactNode
|
|
|
161
161
|
);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/*
|
|
165
|
+
* The path on the host's machine an image target names, or null for a URL and for a relative path
|
|
166
|
+
* nobody gave a folder for. react-markdown has already emptied a target of a scheme it distrusts.
|
|
167
|
+
*/
|
|
168
|
+
function localImagePath(src: string, cwd: string | null): string | null {
|
|
169
|
+
if (src === '' || /^[a-z][a-z0-9+.-]*:/i.test(src)) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const target = hrefPath(src);
|
|
173
|
+
if (target.startsWith('/')) {
|
|
174
|
+
return target;
|
|
175
|
+
}
|
|
176
|
+
if (cwd === null) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
return `${cwd.replace(/\/+$/, '')}/${target.replace(/^\.\//, '')}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* A markdown image, which only the host can read when it names a file on its machine. */
|
|
183
|
+
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
|
|
184
|
+
const { MarkdownImage: HostImage } = useChatHost();
|
|
185
|
+
const cwd = useFileLinkCwd();
|
|
186
|
+
const path = typeof src === 'string' ? localImagePath(src, cwd) : null;
|
|
187
|
+
if (HostImage === undefined || path === null) {
|
|
188
|
+
return <img src={src} alt={alt} />;
|
|
189
|
+
}
|
|
190
|
+
return <HostImage path={path} alt={alt ?? ''} />;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function PlainImage({ src, alt }: { src?: string; alt?: string }) {
|
|
194
|
+
return <img src={src} alt={alt} />;
|
|
195
|
+
}
|
|
196
|
+
|
|
164
197
|
/* A ```mermaid fence, which draws what it says. */
|
|
165
198
|
function Diagram({ code }: { code: string }) {
|
|
166
199
|
const { t } = useTranslation('agent-chat');
|
|
@@ -190,6 +223,7 @@ const components = {
|
|
|
190
223
|
a({ href, children }: { href?: string; children?: ReactNode }) {
|
|
191
224
|
return <MarkdownLink href={href}>{children}</MarkdownLink>;
|
|
192
225
|
},
|
|
226
|
+
img: MarkdownImage,
|
|
193
227
|
table({ children }: { children?: ReactNode }) {
|
|
194
228
|
// A table wider than the column scrolls on its own instead of pushing the thread sideways.
|
|
195
229
|
return (
|
|
@@ -210,7 +244,8 @@ const plainComponents = {
|
|
|
210
244
|
</MarkdownCode>
|
|
211
245
|
);
|
|
212
246
|
},
|
|
213
|
-
a: ExternalLink
|
|
247
|
+
a: ExternalLink,
|
|
248
|
+
img: PlainImage
|
|
214
249
|
};
|
|
215
250
|
|
|
216
251
|
// A reply sits under the heading of its message, an `h3`, so its own headings start one level below
|
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
186
|
+
stopWatching();
|
|
194
187
|
scheme?.removeEventListener('change', read);
|
|
195
188
|
};
|
|
196
189
|
}, [ref, mode]);
|
package/src/host.ts
CHANGED
|
@@ -313,11 +313,16 @@ export interface ChatHost {
|
|
|
313
313
|
};
|
|
314
314
|
/* The image a tool read, drawn under its row; null draws none. */
|
|
315
315
|
ReadImage: ComponentType<{ path: string }> | null;
|
|
316
|
+
/* An image a reply's markdown names by a path on the host's machine, absolute or under the chat's cwd; left out, the `<img>` stays as written. */
|
|
317
|
+
MarkdownImage?: ComponentType<{ path: string; alt: string }>;
|
|
316
318
|
code: {
|
|
317
319
|
useMode(): 'light' | 'dark';
|
|
318
320
|
/* The Shiki theme ids code is drawn in, one per mode. */
|
|
319
321
|
useThemes(): { light: string; dark: string };
|
|
320
|
-
/*
|
|
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
|
+
*/
|
|
321
326
|
custom: ReadonlyArray<ThemeRegistration & { name: string }>;
|
|
322
327
|
/* One syntax worker shared by chat diffs and standalone review panes; omitted, tokenize on the page. */
|
|
323
328
|
syntaxWorker?: () => Worker;
|