@dxos/react-ui-markdown 0.9.1-staging.ee54ba693a → 0.11.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/lib/index.mjs +648 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/types/src/MarkdownStream/MarkdownStream.d.ts +25 -1
- package/dist/types/src/MarkdownStream/MarkdownStream.d.ts.map +1 -1
- package/dist/types/src/MarkdownStream/controller.test.d.ts +2 -0
- package/dist/types/src/MarkdownStream/controller.test.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/create-controller.d.ts +19 -0
- package/dist/types/src/MarkdownStream/create-controller.d.ts.map +1 -0
- package/dist/types/src/MarkdownStream/index.d.ts +1 -0
- package/dist/types/src/MarkdownStream/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +30 -30
- package/src/MarkdownStream/MarkdownStream.tsx +72 -116
- package/src/MarkdownStream/controller.test.ts +143 -0
- package/src/MarkdownStream/create-controller.ts +166 -0
- package/src/MarkdownStream/index.ts +1 -0
- package/src/MarkdownView/MarkdownView.stories.tsx +1 -1
- package/src/MarkdownView/MarkdownView.tsx +1 -1
- package/dist/lib/browser/index.mjs +0 -631
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/react-ui-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Markdown components.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
".": {
|
|
16
16
|
"source": "./src/index.ts",
|
|
17
17
|
"types": "./dist/types/src/index.d.ts",
|
|
18
|
-
"
|
|
18
|
+
"import": "./dist/lib/index.mjs"
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"types": "dist/types/src/index.d.ts",
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
"src"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@codemirror/autocomplete": "^6.20.
|
|
28
|
-
"@codemirror/language": "^6.12.
|
|
29
|
-
"@codemirror/state": "^6.
|
|
30
|
-
"@codemirror/view": "^6.43.
|
|
27
|
+
"@codemirror/autocomplete": "^6.20.3",
|
|
28
|
+
"@codemirror/language": "^6.12.4",
|
|
29
|
+
"@codemirror/state": "^6.7.1",
|
|
30
|
+
"@codemirror/view": "^6.43.6",
|
|
31
31
|
"@lezer/common": "^1.5.2",
|
|
32
32
|
"@lezer/highlight": "^1.2.3",
|
|
33
33
|
"@lezer/markdown": "^1.6.3",
|
|
@@ -39,18 +39,18 @@
|
|
|
39
39
|
"react-markdown": "^10.1.0",
|
|
40
40
|
"react-resize-detector": "^11.0.1",
|
|
41
41
|
"remark-gfm": "^4.0.1",
|
|
42
|
-
"@dxos/async": "0.
|
|
43
|
-
"@dxos/echo": "0.
|
|
44
|
-
"@dxos/effect": "0.
|
|
45
|
-
"@dxos/
|
|
46
|
-
"@dxos/
|
|
47
|
-
"@dxos/react-ui
|
|
48
|
-
"@dxos/ui": "0.
|
|
49
|
-
"@dxos/react-ui-syntax-highlighter": "0.
|
|
50
|
-
"@dxos/ui
|
|
51
|
-
"@dxos/ui-
|
|
52
|
-
"@dxos/
|
|
53
|
-
"@dxos/
|
|
42
|
+
"@dxos/async": "0.11.0",
|
|
43
|
+
"@dxos/echo": "0.11.0",
|
|
44
|
+
"@dxos/effect": "0.11.0",
|
|
45
|
+
"@dxos/invariant": "0.11.0",
|
|
46
|
+
"@dxos/log": "0.11.0",
|
|
47
|
+
"@dxos/react-ui": "0.11.0",
|
|
48
|
+
"@dxos/react-ui-editor": "0.11.0",
|
|
49
|
+
"@dxos/react-ui-syntax-highlighter": "0.11.0",
|
|
50
|
+
"@dxos/ui": "0.11.0",
|
|
51
|
+
"@dxos/ui-editor": "0.11.0",
|
|
52
|
+
"@dxos/util": "0.11.0",
|
|
53
|
+
"@dxos/ui-theme": "0.11.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@effect/vitest": "0.29.0",
|
|
@@ -58,23 +58,23 @@
|
|
|
58
58
|
"@types/react-dom": "~19.2.3",
|
|
59
59
|
"react": "~19.2.7",
|
|
60
60
|
"react-dom": "~19.2.7",
|
|
61
|
-
"vite": "^8.
|
|
62
|
-
"@dxos/
|
|
63
|
-
"@dxos/
|
|
64
|
-
"@dxos/lit-ui": "0.
|
|
65
|
-
"@dxos/random": "0.
|
|
66
|
-
"@dxos/react-client": "0.
|
|
67
|
-
"@dxos/react-ui": "0.
|
|
68
|
-
"@dxos/schema": "0.
|
|
69
|
-
"@dxos/storybook-utils": "0.
|
|
70
|
-
"@dxos/ui-theme": "0.
|
|
61
|
+
"vite": "^8.1.4",
|
|
62
|
+
"@dxos/echo": "0.11.0",
|
|
63
|
+
"@dxos/keys": "0.11.0",
|
|
64
|
+
"@dxos/lit-ui": "0.11.0",
|
|
65
|
+
"@dxos/random": "0.11.0",
|
|
66
|
+
"@dxos/react-client": "0.11.0",
|
|
67
|
+
"@dxos/react-ui": "0.11.0",
|
|
68
|
+
"@dxos/schema": "0.11.0",
|
|
69
|
+
"@dxos/storybook-utils": "0.11.0",
|
|
70
|
+
"@dxos/ui-theme": "0.11.0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"effect": "3.21.4",
|
|
74
74
|
"react": "~19.2.7",
|
|
75
75
|
"react-dom": "~19.2.7",
|
|
76
|
-
"@dxos/react-ui": "0.
|
|
77
|
-
"@dxos/ui-theme": "0.
|
|
76
|
+
"@dxos/react-ui": "^0.11.0",
|
|
77
|
+
"@dxos/ui-theme": "^0.11.0"
|
|
78
78
|
},
|
|
79
79
|
"publishConfig": {
|
|
80
80
|
"access": "public"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { EditorSelection, type Extension, Transaction } from '@codemirror/state';
|
|
6
|
-
import {
|
|
6
|
+
import { EditorView } from '@codemirror/view';
|
|
7
7
|
import * as Effect from 'effect/Effect';
|
|
8
8
|
import * as Fiber from 'effect/Fiber';
|
|
9
9
|
import * as Queue from 'effect/Queue';
|
|
@@ -26,20 +26,21 @@ import { ErrorBoundary, type ThemedClassName, useDynamicRef, useStateWithRef, us
|
|
|
26
26
|
import { type UseTextEditor, useTextEditor } from '@dxos/react-ui-editor';
|
|
27
27
|
import {
|
|
28
28
|
type AutoScrollProps,
|
|
29
|
+
PROMPT_ELEMENT,
|
|
30
|
+
ThemeExtensionsOptions,
|
|
29
31
|
type XmlTagsOptions,
|
|
30
32
|
type XmlWidgetState,
|
|
31
33
|
type XmlWidgetStateManager,
|
|
32
|
-
crawlerLineEffect,
|
|
33
34
|
createBasicExtensions,
|
|
34
35
|
createThemeExtensions,
|
|
36
|
+
createTurnSource,
|
|
35
37
|
decorateMarkdown,
|
|
36
38
|
documentSlots,
|
|
37
39
|
extendedMarkdown,
|
|
38
40
|
fader,
|
|
39
41
|
lineSpacing,
|
|
40
|
-
navigateNextEffect,
|
|
41
|
-
navigatePreviousEffect,
|
|
42
42
|
scroller,
|
|
43
|
+
turnFolding,
|
|
43
44
|
typewriter,
|
|
44
45
|
typewriterBypass,
|
|
45
46
|
xmlBlockDecoration,
|
|
@@ -47,24 +48,38 @@ import {
|
|
|
47
48
|
xmlTagContextEffect,
|
|
48
49
|
xmlTagResetEffect,
|
|
49
50
|
xmlTags,
|
|
50
|
-
xmlTagUpdateEffect,
|
|
51
51
|
} from '@dxos/ui-editor';
|
|
52
52
|
import { mx } from '@dxos/ui-theme';
|
|
53
53
|
import { isTruthy } from '@dxos/util';
|
|
54
54
|
|
|
55
|
+
import { createMarkdownStreamController } from './create-controller';
|
|
55
56
|
import { footer, setFooterVisibleEffect } from './footer';
|
|
56
57
|
import { type StreamerOptions, createStreamer } from './stream';
|
|
58
|
+
|
|
59
|
+
/** Document offset range (CodeMirror positions). */
|
|
60
|
+
export type DocumentRange = { from: number; to: number };
|
|
61
|
+
|
|
57
62
|
export interface MarkdownStreamController extends XmlWidgetStateManager {
|
|
58
63
|
get length(): number | undefined;
|
|
59
64
|
focus: () => void;
|
|
60
65
|
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
66
|
+
/** Scroll the given document position into view. */
|
|
67
|
+
scrollTo: (pos: number, options?: { y?: 'start' | 'center' | 'end' | 'nearest' }) => void;
|
|
68
|
+
/** The document range currently visible in the viewport, or `undefined` if not mounted. */
|
|
69
|
+
getVisibleRange: () => DocumentRange | undefined;
|
|
70
|
+
/** Subscribe to visible-range changes (fires immediately with the current range). Returns an unsubscribe fn. */
|
|
71
|
+
onVisibleRangeChange: (cb: (range: DocumentRange) => void) => () => void;
|
|
61
72
|
navigateNext: () => void;
|
|
62
73
|
navigatePrevious: () => void;
|
|
63
74
|
setContext: (context: any) => void;
|
|
75
|
+
/** Re-applies the last context set. Idempotent; a no-op when none was set or the view is absent. */
|
|
76
|
+
flushContext: () => void;
|
|
64
77
|
setContent: (text: string) => Promise<void>;
|
|
65
78
|
append: (text: string) => Promise<void>;
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
/** Map the scroll container's top/bottom edges to document positions. */
|
|
82
|
+
|
|
68
83
|
export type MarkdownStreamEvent = {
|
|
69
84
|
type: 'submit';
|
|
70
85
|
value: string | null;
|
|
@@ -113,6 +128,11 @@ export type MarkdownStreamProps = ThemedClassName<
|
|
|
113
128
|
*/
|
|
114
129
|
extensions?: Extension;
|
|
115
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Theme extensions.
|
|
133
|
+
*/
|
|
134
|
+
slots?: ThemeExtensionsOptions['slots'];
|
|
135
|
+
|
|
116
136
|
/** Event handler. */
|
|
117
137
|
onEvent?: (event: MarkdownStreamEvent) => void;
|
|
118
138
|
} & (XmlTagsOptions & AutoScrollProps)
|
|
@@ -122,7 +142,7 @@ export type MarkdownStreamProps = ThemedClassName<
|
|
|
122
142
|
* Codemirror-based markdown editor with xml tag widtgets and streaming support.
|
|
123
143
|
*/
|
|
124
144
|
export const MarkdownStream = forwardRef<MarkdownStreamController | null, MarkdownStreamProps>(
|
|
125
|
-
({ classNames, debug, content, options, registry, extensions, footer, onEvent }, forwardedRef) => {
|
|
145
|
+
({ classNames, debug, content, options, registry, extensions, footer, slots, onEvent }, forwardedRef) => {
|
|
126
146
|
// Store current content so that we can toggle debug mode. Default to '' so the
|
|
127
147
|
// `append()` path (which does `contentRef.current += text`) doesn't concatenate
|
|
128
148
|
// against `undefined` and stamp `"undefined"` into the transcript snapshot.
|
|
@@ -131,8 +151,12 @@ export const MarkdownStream = forwardRef<MarkdownStreamController | null, Markdo
|
|
|
131
151
|
// DOM node for the footer block widget — populated when its decoration mounts.
|
|
132
152
|
const [footerRoot, setFooterRoot] = useState<HTMLElement | null>(null);
|
|
133
153
|
|
|
154
|
+
// Survives a context set before the view exists; flushed once it does (see `flushContext`).
|
|
155
|
+
const pendingContextRef = useRef<{ value: any } | undefined>(undefined);
|
|
156
|
+
|
|
134
157
|
// Codemirror editor.
|
|
135
158
|
const { parentRef, view, viewRef, widgets } = useMarkdownStreamTextEditor(contentRef, {
|
|
159
|
+
slots,
|
|
136
160
|
debug,
|
|
137
161
|
registry,
|
|
138
162
|
options,
|
|
@@ -157,9 +181,11 @@ export const MarkdownStream = forwardRef<MarkdownStreamController | null, Markdo
|
|
|
157
181
|
return;
|
|
158
182
|
}
|
|
159
183
|
|
|
160
|
-
// Set content and scroll to bottom.
|
|
184
|
+
// Set content and scroll to bottom. Re-assert the host context rather than clearing it: it
|
|
185
|
+
// belongs to the host, not the document, so nulling it here would strand every widget in the
|
|
186
|
+
// replacement document with `context: undefined`.
|
|
161
187
|
viewRef.current.dispatch({
|
|
162
|
-
effects: [xmlTagContextEffect.of(null), xmlTagResetEffect.of(null)],
|
|
188
|
+
effects: [xmlTagContextEffect.of(pendingContextRef.current?.value ?? null), xmlTagResetEffect.of(null)],
|
|
163
189
|
changes: [{ from: 0, to: viewRef.current.state.doc.length, insert: text }],
|
|
164
190
|
annotations: typewriterBypass.of(true),
|
|
165
191
|
selection: EditorSelection.cursor(text.length),
|
|
@@ -168,16 +194,25 @@ export const MarkdownStream = forwardRef<MarkdownStreamController | null, Markdo
|
|
|
168
194
|
// New queue.
|
|
169
195
|
setQueue(Effect.runSync(Queue.unbounded<string>()));
|
|
170
196
|
},
|
|
171
|
-
[contentRef, viewRef, setQueue],
|
|
197
|
+
[contentRef, viewRef, setQueue, pendingContextRef],
|
|
172
198
|
);
|
|
173
199
|
|
|
174
200
|
// Controller API.
|
|
175
201
|
useImperativeHandle(
|
|
176
202
|
forwardedRef,
|
|
177
|
-
() => createMarkdownStreamController({ contentRef, viewRef, queueRef, onReset }),
|
|
203
|
+
() => createMarkdownStreamController({ contentRef, viewRef, queueRef, onReset, pendingContextRef }),
|
|
178
204
|
[onReset],
|
|
179
205
|
);
|
|
180
206
|
|
|
207
|
+
// Apply a context that was set before the view existed — the dispatch in `setContext` is a no-op
|
|
208
|
+
// until then, and a host mounting the controller has no way to know.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const pending = pendingContextRef.current;
|
|
211
|
+
if (view && pending) {
|
|
212
|
+
view.dispatch({ effects: xmlTagContextEffect.of(pending.value) });
|
|
213
|
+
}
|
|
214
|
+
}, [view]);
|
|
215
|
+
|
|
181
216
|
// Widget events.
|
|
182
217
|
useEffect(() => {
|
|
183
218
|
if (!parentRef.current) {
|
|
@@ -226,9 +261,13 @@ export const MarkdownStream = forwardRef<MarkdownStreamController | null, Markdo
|
|
|
226
261
|
},
|
|
227
262
|
);
|
|
228
263
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
264
|
+
// Fold each agent response beneath its `<prompt>` (the head element rendered by `xmlBlockDecoration`).
|
|
265
|
+
const turnSource = createTurnSource(PROMPT_ELEMENT);
|
|
266
|
+
|
|
267
|
+
type MarkdownStreamTextEditorParams = Pick<MarkdownStreamProps, 'debug' | 'registry' | 'options' | 'extensions'> &
|
|
268
|
+
Pick<ThemeExtensionsOptions, 'slots'> & {
|
|
269
|
+
setFooterRoot?: (el: HTMLElement | null) => void;
|
|
270
|
+
};
|
|
232
271
|
|
|
233
272
|
type MarkdownStreamTextEditorResult = UseTextEditor & {
|
|
234
273
|
viewRef: RefObject<EditorView | null>;
|
|
@@ -240,7 +279,14 @@ type MarkdownStreamTextEditorResult = UseTextEditor & {
|
|
|
240
279
|
*/
|
|
241
280
|
const useMarkdownStreamTextEditor = (
|
|
242
281
|
currentContent: RefObject<string | undefined>,
|
|
243
|
-
{
|
|
282
|
+
{
|
|
283
|
+
debug,
|
|
284
|
+
registry,
|
|
285
|
+
options,
|
|
286
|
+
extensions: extensionsProp,
|
|
287
|
+
slots = documentSlots,
|
|
288
|
+
setFooterRoot,
|
|
289
|
+
}: MarkdownStreamTextEditorParams,
|
|
244
290
|
): MarkdownStreamTextEditorResult => {
|
|
245
291
|
const { themeMode } = useThemeContext();
|
|
246
292
|
|
|
@@ -254,40 +300,31 @@ const useMarkdownStreamTextEditor = (
|
|
|
254
300
|
initialValue: content,
|
|
255
301
|
selection: EditorSelection.cursor(content?.length ?? 0),
|
|
256
302
|
extensions: [
|
|
257
|
-
createBasicExtensions({
|
|
258
|
-
|
|
259
|
-
readOnly: true,
|
|
260
|
-
}),
|
|
261
|
-
createThemeExtensions({
|
|
262
|
-
slots: documentSlots,
|
|
263
|
-
scrollbarThin: true,
|
|
264
|
-
syntaxHighlighting: true,
|
|
265
|
-
themeMode,
|
|
266
|
-
}),
|
|
303
|
+
createBasicExtensions({ lineWrapping: true, readOnly: true }),
|
|
304
|
+
createThemeExtensions({ slots, scrollbarThin: true, syntaxHighlighting: true, themeMode }),
|
|
267
305
|
xmlFormatting({ skip: debug ? [] : ['prompt'] }),
|
|
268
306
|
!debug &&
|
|
269
307
|
[
|
|
270
308
|
extendedMarkdown({ registry }),
|
|
271
309
|
decorateMarkdown({
|
|
272
|
-
// `echo
|
|
273
|
-
// registry via `xmlTags` `urlSchemes`. Skipping them here avoids `decorateMarkdown`
|
|
274
|
-
// adding a non-functional `LinkButton` anchor on top of the same node.
|
|
310
|
+
// xmlTags extension will handle `dxn:`/`echo:` links/images.
|
|
275
311
|
skip: (node) =>
|
|
276
312
|
(node.name === 'Link' || node.name === 'Image') &&
|
|
277
313
|
(node.url.startsWith('dxn:') || node.url.startsWith('echo:')),
|
|
278
314
|
}),
|
|
279
315
|
// TODO(burdon): Make optional; Removes need for '\n\n'.
|
|
280
316
|
lineSpacing(),
|
|
281
|
-
// NOTE: An ancestor element must set `data-hue` so `.dx-panel` resolves to the user's
|
|
282
|
-
// hue tokens (see `packages/ui/ui-theme/src/css/components/panel.css`). Tailwind picks
|
|
283
|
-
// up these utility classes from this source file.
|
|
284
317
|
xmlBlockDecoration({
|
|
285
318
|
tag: 'prompt',
|
|
286
|
-
lineClass:
|
|
287
|
-
|
|
319
|
+
lineClass:
|
|
320
|
+
'cm-prompt-line cm-prompt-bubble bg-group-surface text-base-fg border-l-[8px] pl-[8px]! pr-2 [&_*]:text-inherit!',
|
|
321
|
+
firstLineClass: 'pt-1.5 rounded-t-sm',
|
|
322
|
+
lastLineClass: 'pb-1.5 rounded-b-sm',
|
|
288
323
|
hideTags: true,
|
|
289
324
|
}),
|
|
290
325
|
xmlTags({ registry, setWidgets, bookmarks: ['prompt'] }),
|
|
326
|
+
// TODO(burdon): Folding gets progressively off due to some widgets?
|
|
327
|
+
turnFolding({ source: turnSource }),
|
|
291
328
|
scroller({ overScroll: 80, autoScroll: options?.autoScroll }),
|
|
292
329
|
options?.typewriter &&
|
|
293
330
|
typewriter({
|
|
@@ -301,7 +338,7 @@ const useMarkdownStreamTextEditor = (
|
|
|
301
338
|
options?.fader && fader(),
|
|
302
339
|
setFooterRoot && footer(setFooterRoot),
|
|
303
340
|
].filter(isTruthy),
|
|
304
|
-
|
|
341
|
+
extensionsProp,
|
|
305
342
|
].filter(isTruthy),
|
|
306
343
|
};
|
|
307
344
|
}, [
|
|
@@ -312,7 +349,8 @@ const useMarkdownStreamTextEditor = (
|
|
|
312
349
|
options?.typewriter,
|
|
313
350
|
options?.cursor,
|
|
314
351
|
options?.fader,
|
|
315
|
-
|
|
352
|
+
slots,
|
|
353
|
+
extensionsProp,
|
|
316
354
|
]);
|
|
317
355
|
|
|
318
356
|
const viewRef = useDynamicRef(view);
|
|
@@ -360,85 +398,3 @@ const useMarkdownStreamQueue = (
|
|
|
360
398
|
};
|
|
361
399
|
}, [view, queue, chunkSize, delayMs]);
|
|
362
400
|
};
|
|
363
|
-
|
|
364
|
-
type MarkdownStreamControllerDeps = {
|
|
365
|
-
contentRef: RefObject<string | undefined>;
|
|
366
|
-
viewRef: RefObject<EditorView | null>;
|
|
367
|
-
queueRef: RefObject<Queue.Queue<string>>;
|
|
368
|
-
onReset: (text: string) => Promise<void>;
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* External controller API.
|
|
373
|
-
*/
|
|
374
|
-
const createMarkdownStreamController = ({
|
|
375
|
-
contentRef,
|
|
376
|
-
viewRef,
|
|
377
|
-
queueRef,
|
|
378
|
-
onReset,
|
|
379
|
-
}: MarkdownStreamControllerDeps): MarkdownStreamController => {
|
|
380
|
-
return {
|
|
381
|
-
get length() {
|
|
382
|
-
return viewRef.current?.state.doc.length;
|
|
383
|
-
},
|
|
384
|
-
|
|
385
|
-
/** Focus the editor. */
|
|
386
|
-
focus: () => {
|
|
387
|
-
viewRef.current?.focus();
|
|
388
|
-
},
|
|
389
|
-
|
|
390
|
-
/** Scroll to bottom. */
|
|
391
|
-
scrollToBottom: (behavior?: ScrollBehavior) => {
|
|
392
|
-
viewRef.current?.dispatch({
|
|
393
|
-
effects: crawlerLineEffect.of({ line: -1, behavior }),
|
|
394
|
-
});
|
|
395
|
-
},
|
|
396
|
-
|
|
397
|
-
/** Navigate previous prompt. */
|
|
398
|
-
navigatePrevious: () => {
|
|
399
|
-
viewRef.current?.dispatch({
|
|
400
|
-
effects: navigatePreviousEffect.of(),
|
|
401
|
-
});
|
|
402
|
-
},
|
|
403
|
-
|
|
404
|
-
/** Navigate next prompt. */
|
|
405
|
-
navigateNext: () => {
|
|
406
|
-
viewRef.current?.dispatch({
|
|
407
|
-
effects: navigateNextEffect.of(),
|
|
408
|
-
});
|
|
409
|
-
},
|
|
410
|
-
|
|
411
|
-
/** Set the context for widgets (XML tags). */
|
|
412
|
-
setContext: (context: any) => {
|
|
413
|
-
viewRef.current?.dispatch({
|
|
414
|
-
effects: xmlTagContextEffect.of(context),
|
|
415
|
-
});
|
|
416
|
-
},
|
|
417
|
-
|
|
418
|
-
/** Reset document. */
|
|
419
|
-
setContent: onReset,
|
|
420
|
-
|
|
421
|
-
/** Append to queue (and stream). */
|
|
422
|
-
append: async (text: string) => {
|
|
423
|
-
contentRef.current += text;
|
|
424
|
-
if (text.length) {
|
|
425
|
-
// Always go through the streaming queue, even when the doc starts empty. Skipping the
|
|
426
|
-
// queue in that case (via `onReset`) bypasses the `typewriter` extension's transaction filter
|
|
427
|
-
// and the first chunk lands in one CM dispatch — defeating the typewriter for any
|
|
428
|
-
// consumer (e.g. ChatThread) where the first delta is large because upstream batching
|
|
429
|
-
// collected several streaming partials before React rendered.
|
|
430
|
-
const queue = queueRef.current;
|
|
431
|
-
if (queue) {
|
|
432
|
-
await EffectEx.runAndForwardErrors(Queue.offer(queue, text));
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
},
|
|
436
|
-
|
|
437
|
-
/** Update widget state. */
|
|
438
|
-
updateWidget: (id: string, value: any) => {
|
|
439
|
-
viewRef.current?.dispatch({
|
|
440
|
-
effects: xmlTagUpdateEffect.of({ id, value }),
|
|
441
|
-
});
|
|
442
|
-
},
|
|
443
|
-
} satisfies MarkdownStreamController;
|
|
444
|
-
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @vitest-environment jsdom
|
|
6
|
+
|
|
7
|
+
import { EditorState } from '@codemirror/state';
|
|
8
|
+
import { EditorView } from '@codemirror/view';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Queue from 'effect/Queue';
|
|
11
|
+
import { describe, test } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type XmlWidgetDef,
|
|
15
|
+
decorationSetToArray,
|
|
16
|
+
extendedMarkdown,
|
|
17
|
+
xmlTagContextEffect,
|
|
18
|
+
xmlTags,
|
|
19
|
+
} from '@dxos/ui-editor';
|
|
20
|
+
|
|
21
|
+
import { createMarkdownStreamController } from './create-controller';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The host↔editor seam: a host mounts the controller and publishes the context widgets call back
|
|
25
|
+
* through, but the controller's dispatches are no-ops until the editor view exists — and the host has no
|
|
26
|
+
* signal for when that is. These pin the contract that a context set at any point still reaches widgets.
|
|
27
|
+
*/
|
|
28
|
+
describe('MarkdownStreamController context', () => {
|
|
29
|
+
test('a context set before the view exists is applied once it does', ({ expect }) => {
|
|
30
|
+
const harness = createHarness();
|
|
31
|
+
const context = { rewind: () => {} };
|
|
32
|
+
|
|
33
|
+
// No view yet — this is what a host mounting the controller in an effect actually does.
|
|
34
|
+
harness.controller.setContext(context);
|
|
35
|
+
expect(harness.widgetContext()).toBeUndefined();
|
|
36
|
+
|
|
37
|
+
harness.attachView();
|
|
38
|
+
harness.controller.flushContext();
|
|
39
|
+
expect(harness.widgetContext()).toBe(context);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a context set after the view exists is applied immediately', ({ expect }) => {
|
|
43
|
+
const harness = createHarness();
|
|
44
|
+
harness.attachView();
|
|
45
|
+
|
|
46
|
+
const context = { rewind: () => {} };
|
|
47
|
+
harness.controller.setContext(context);
|
|
48
|
+
expect(harness.widgetContext()).toBe(context);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('flushContext is a no-op when no context was set', ({ expect }) => {
|
|
52
|
+
const harness = createHarness();
|
|
53
|
+
harness.attachView();
|
|
54
|
+
|
|
55
|
+
harness.controller.flushContext();
|
|
56
|
+
expect(harness.widgetContext()).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// `setContent` resets the document and rebuilds decorations, which used to clear the context — the
|
|
60
|
+
// context belongs to the host, not the document, so it has to survive.
|
|
61
|
+
test('the context survives a document reset', async ({ expect }) => {
|
|
62
|
+
const harness = createHarness();
|
|
63
|
+
harness.attachView();
|
|
64
|
+
|
|
65
|
+
const context = { rewind: () => {} };
|
|
66
|
+
harness.controller.setContext(context);
|
|
67
|
+
await harness.controller.setContent('<branch id="b" />');
|
|
68
|
+
|
|
69
|
+
expect(harness.widgetContext('b')).toBe(context);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the latest context wins', ({ expect }) => {
|
|
73
|
+
const harness = createHarness();
|
|
74
|
+
harness.attachView();
|
|
75
|
+
|
|
76
|
+
const first = { rewind: () => {} };
|
|
77
|
+
const second = { rewind: () => {} };
|
|
78
|
+
harness.controller.setContext(first);
|
|
79
|
+
harness.controller.setContext(second);
|
|
80
|
+
expect(harness.widgetContext()).toBe(second);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const registry: Record<string, XmlWidgetDef> = {
|
|
85
|
+
branch: { block: true, Component: () => null },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Mirrors how `MarkdownStream` wires the controller: refs the host holds, a view created later, and
|
|
90
|
+
* `onReset` replacing the document while re-asserting the host context.
|
|
91
|
+
*/
|
|
92
|
+
const createHarness = () => {
|
|
93
|
+
const contentRef = { current: '<branch id="a" />' };
|
|
94
|
+
const viewRef: { current: EditorView | null } = { current: null };
|
|
95
|
+
const queueRef = { current: Effect.runSync(Queue.unbounded<string>()) };
|
|
96
|
+
const pendingContextRef: { current: { value: any } | undefined } = { current: undefined };
|
|
97
|
+
|
|
98
|
+
const controller = createMarkdownStreamController({
|
|
99
|
+
contentRef,
|
|
100
|
+
viewRef,
|
|
101
|
+
queueRef,
|
|
102
|
+
pendingContextRef,
|
|
103
|
+
onReset: async (text) => {
|
|
104
|
+
contentRef.current = text;
|
|
105
|
+
viewRef.current?.dispatch({
|
|
106
|
+
effects: [xmlTagContextEffect.of(pendingContextRef.current?.value ?? null)],
|
|
107
|
+
changes: [{ from: 0, to: viewRef.current.state.doc.length, insert: text }],
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
controller,
|
|
114
|
+
attachView: () => {
|
|
115
|
+
viewRef.current = new EditorView({
|
|
116
|
+
state: EditorState.create({
|
|
117
|
+
doc: contentRef.current,
|
|
118
|
+
extensions: [extendedMarkdown({ registry }), xmlTags({ registry })],
|
|
119
|
+
}),
|
|
120
|
+
parent: document.createElement('div'),
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
/** The `context` prop the named widget's decoration was built with. */
|
|
124
|
+
widgetContext: (id = 'a') => {
|
|
125
|
+
const view = viewRef.current;
|
|
126
|
+
if (!view) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
for (const source of view.state.facet(EditorView.decorations)) {
|
|
130
|
+
const set = typeof source === 'function' ? source(view) : source;
|
|
131
|
+
if (!set) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const { value } of decorationSetToArray(set)) {
|
|
135
|
+
const widget = value.spec?.widget;
|
|
136
|
+
if (widget?.id === id) {
|
|
137
|
+
return widget.props?.context;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
};
|