@dxos/react-ui-assistant 0.11.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.
Files changed (91) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/index.mjs +839 -0
  3. package/dist/lib/index.mjs.map +1 -0
  4. package/dist/lib/testing.mjs +214 -0
  5. package/dist/lib/testing.mjs.map +1 -0
  6. package/dist/types/src/components/ChatThread/ChatThread.d.ts +52 -0
  7. package/dist/types/src/components/ChatThread/ChatThread.d.ts.map +1 -0
  8. package/dist/types/src/components/ChatThread/ChatThread.stories.d.ts +38 -0
  9. package/dist/types/src/components/ChatThread/ChatThread.stories.d.ts.map +1 -0
  10. package/dist/types/src/components/ChatThread/index.d.ts +2 -0
  11. package/dist/types/src/components/ChatThread/index.d.ts.map +1 -0
  12. package/dist/types/src/components/MessageChrome/MessageChrome.d.ts +28 -0
  13. package/dist/types/src/components/MessageChrome/MessageChrome.d.ts.map +1 -0
  14. package/dist/types/src/components/MessageChrome/index.d.ts +2 -0
  15. package/dist/types/src/components/MessageChrome/index.d.ts.map +1 -0
  16. package/dist/types/src/components/index.d.ts +4 -0
  17. package/dist/types/src/components/index.d.ts.map +1 -0
  18. package/dist/types/src/components/widgets/FallbackWidget.d.ts +7 -0
  19. package/dist/types/src/components/widgets/FallbackWidget.d.ts.map +1 -0
  20. package/dist/types/src/components/widgets/ReasoningWidget.d.ts +25 -0
  21. package/dist/types/src/components/widgets/ReasoningWidget.d.ts.map +1 -0
  22. package/dist/types/src/components/widgets/ReasoningWidget.stories.d.ts +18 -0
  23. package/dist/types/src/components/widgets/ReasoningWidget.stories.d.ts.map +1 -0
  24. package/dist/types/src/components/widgets/ReferenceWidget.d.ts +9 -0
  25. package/dist/types/src/components/widgets/ReferenceWidget.d.ts.map +1 -0
  26. package/dist/types/src/components/widgets/SelectWidget.d.ts +14 -0
  27. package/dist/types/src/components/widgets/SelectWidget.d.ts.map +1 -0
  28. package/dist/types/src/components/widgets/StatsWidget.d.ts +12 -0
  29. package/dist/types/src/components/widgets/StatsWidget.d.ts.map +1 -0
  30. package/dist/types/src/components/widgets/StatusWidget.d.ts +12 -0
  31. package/dist/types/src/components/widgets/StatusWidget.d.ts.map +1 -0
  32. package/dist/types/src/components/widgets/SuggestionWidget.d.ts +11 -0
  33. package/dist/types/src/components/widgets/SuggestionWidget.d.ts.map +1 -0
  34. package/dist/types/src/components/widgets/SummaryWidget.d.ts +4 -0
  35. package/dist/types/src/components/widgets/SummaryWidget.d.ts.map +1 -0
  36. package/dist/types/src/components/widgets/ToolWidget.d.ts +10 -0
  37. package/dist/types/src/components/widgets/ToolWidget.d.ts.map +1 -0
  38. package/dist/types/src/components/widgets/ToolWidget.stories.d.ts +31 -0
  39. package/dist/types/src/components/widgets/ToolWidget.stories.d.ts.map +1 -0
  40. package/dist/types/src/components/widgets/defaults.d.ts +11 -0
  41. package/dist/types/src/components/widgets/defaults.d.ts.map +1 -0
  42. package/dist/types/src/components/widgets/format-time.d.ts +2 -0
  43. package/dist/types/src/components/widgets/format-time.d.ts.map +1 -0
  44. package/dist/types/src/components/widgets/index.d.ts +11 -0
  45. package/dist/types/src/components/widgets/index.d.ts.map +1 -0
  46. package/dist/types/src/index.d.ts +6 -0
  47. package/dist/types/src/index.d.ts.map +1 -0
  48. package/dist/types/src/registry.d.ts +11 -0
  49. package/dist/types/src/registry.d.ts.map +1 -0
  50. package/dist/types/src/renderer.d.ts +25 -0
  51. package/dist/types/src/renderer.d.ts.map +1 -0
  52. package/dist/types/src/testing/index.d.ts +2 -0
  53. package/dist/types/src/testing/index.d.ts.map +1 -0
  54. package/dist/types/src/testing/test-generator.d.ts +7 -0
  55. package/dist/types/src/testing/test-generator.d.ts.map +1 -0
  56. package/dist/types/src/testing/test-generator.test.d.ts +2 -0
  57. package/dist/types/src/testing/test-generator.test.d.ts.map +1 -0
  58. package/dist/types/src/translations.d.ts +19 -0
  59. package/dist/types/src/translations.d.ts.map +1 -0
  60. package/dist/types/src/types.d.ts +28 -0
  61. package/dist/types/src/types.d.ts.map +1 -0
  62. package/dist/types/tsconfig.tsbuildinfo +1 -0
  63. package/package.json +67 -0
  64. package/src/components/ChatThread/ChatThread.stories.tsx +419 -0
  65. package/src/components/ChatThread/ChatThread.tsx +188 -0
  66. package/src/components/ChatThread/index.ts +5 -0
  67. package/src/components/MessageChrome/MessageChrome.tsx +147 -0
  68. package/src/components/MessageChrome/index.ts +5 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/widgets/FallbackWidget.tsx +26 -0
  71. package/src/components/widgets/ReasoningWidget.stories.tsx +68 -0
  72. package/src/components/widgets/ReasoningWidget.ts +139 -0
  73. package/src/components/widgets/ReferenceWidget.ts +28 -0
  74. package/src/components/widgets/SelectWidget.ts +39 -0
  75. package/src/components/widgets/StatsWidget.ts +31 -0
  76. package/src/components/widgets/StatusWidget.ts +53 -0
  77. package/src/components/widgets/SuggestionWidget.ts +50 -0
  78. package/src/components/widgets/SummaryWidget.tsx +27 -0
  79. package/src/components/widgets/ToolWidget.stories.tsx +55 -0
  80. package/src/components/widgets/ToolWidget.tsx +185 -0
  81. package/src/components/widgets/defaults.ts +14 -0
  82. package/src/components/widgets/format-time.ts +45 -0
  83. package/src/components/widgets/index.ts +14 -0
  84. package/src/index.ts +9 -0
  85. package/src/registry.tsx +144 -0
  86. package/src/renderer.ts +216 -0
  87. package/src/testing/index.ts +5 -0
  88. package/src/testing/test-generator.test.ts +75 -0
  89. package/src/testing/test-generator.ts +280 -0
  90. package/src/translations.ts +27 -0
  91. package/src/types.ts +32 -0
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@dxos/react-ui-assistant",
3
+ "version": "0.11.1",
4
+ "description": "Assistant chat thread on the virtualized feed.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "DXOS.org",
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "source": "./src/index.ts",
17
+ "types": "./dist/types/src/index.d.ts",
18
+ "import": "./dist/lib/index.mjs"
19
+ },
20
+ "./testing": {
21
+ "source": "./src/testing/index.ts",
22
+ "types": "./dist/types/src/testing/index.d.ts",
23
+ "import": "./dist/lib/testing.mjs"
24
+ }
25
+ },
26
+ "types": "dist/types/src/index.d.ts",
27
+ "files": [
28
+ "dist",
29
+ "src"
30
+ ],
31
+ "dependencies": {
32
+ "@codemirror/view": "^6.43.8",
33
+ "@radix-ui/react-context": "1.1.1",
34
+ "effect": "4.0.0-rc.108",
35
+ "@dxos/echo": "0.11.1",
36
+ "@dxos/keys": "0.11.1",
37
+ "@dxos/react-ui": "0.11.1",
38
+ "@dxos/react-ui-feed": "0.11.1",
39
+ "@dxos/types": "0.11.1",
40
+ "@dxos/react-ui-components": "0.11.1",
41
+ "@dxos/react-ui-syntax-highlighter": "0.11.1",
42
+ "@dxos/ui": "0.11.1",
43
+ "@dxos/ui-editor": "0.11.1",
44
+ "@dxos/ui-theme": "0.11.1",
45
+ "@dxos/util": "0.11.1"
46
+ },
47
+ "devDependencies": {
48
+ "@storybook/react-vite": "^10.5.8",
49
+ "@types/react": "~19.2.17",
50
+ "@types/react-dom": "~19.2.3",
51
+ "react": "~19.2.7",
52
+ "react-dom": "~19.2.7",
53
+ "vite": "^8.2.1",
54
+ "@dxos/effect": "0.11.1",
55
+ "@dxos/random": "0.11.1",
56
+ "@dxos/react-ui-markdown": "0.11.1"
57
+ },
58
+ "peerDependencies": {
59
+ "react": "~19.2.7",
60
+ "react-dom": "~19.2.7",
61
+ "@dxos/react-ui": "^0.11.1",
62
+ "@dxos/ui-theme": "^0.11.1"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ }
67
+ }
@@ -0,0 +1,419 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
7
+ import { expect } from 'storybook/test';
8
+
9
+ import { IconButton, Input, Panel, Toolbar } from '@dxos/react-ui';
10
+ import { FeedModel, MessageList, Outline, type OutlineMarker, useMessageList } from '@dxos/react-ui-feed';
11
+ import { Debug, DebugProvider, useDebugProbes, useFrameMeter } from '@dxos/react-ui-feed/debug';
12
+ import { createScenario, streamTurn } from '@dxos/react-ui-feed/testing';
13
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
14
+ import { Message } from '@dxos/types';
15
+
16
+ import { translations } from '../../translations';
17
+ import { type ChatThreadEvent, type ChatView } from '../../types';
18
+ import { ChatThread } from './ChatThread';
19
+
20
+ /**
21
+ * The canonical assistant thread: `ChatThread` end to end — the view-typed renderer, the real
22
+ * widget registry, the prompt/answer chrome — with a reader typing prompts while an agent streams
23
+ * multi-block answers into the tail (status, reasoning, a tool run, then the answer: exactly the
24
+ * block sequence a model emits, via `streamTurn`).
25
+ *
26
+ * Everything goes through the shipping path and the model's *told* APIs: a typed prompt is
27
+ * `model.append`, the agent's turn mutates one identity via `model.patch`, and the follow keeps
28
+ * the tail at rest because the reader is there — scrolling up withdraws it, returning restores it.
29
+ */
30
+ type StoryArgs = {
31
+ count?: number;
32
+ viewType?: ChatView;
33
+ debug?: boolean;
34
+ wordsPerChunk?: number;
35
+ chunkDelay?: number;
36
+ };
37
+
38
+ const DefaultStory = ({ count = 30, viewType = 'thinking', debug, wordsPerChunk = 4, chunkDelay = 120 }: StoryArgs) => {
39
+ const definition = useMemo(() => createScenario({ scenario: 'assistant', count }), [count]);
40
+ const model = useMemo(() => new FeedModel({ messages: definition.messages, stops: 'prompt' }), [definition]);
41
+
42
+ const [busy, setBusy] = useState(false);
43
+ const [auto, setAuto] = useState(false);
44
+ const autoRef = useRef(false);
45
+
46
+ // One agent turn: the answer arrives as blocks that shrink and rewrite as well as grow.
47
+ const answer = useCallback(
48
+ async (prompt: string) => {
49
+ setBusy(true);
50
+ model.append([
51
+ Message.make({ sender: { role: 'user', name: 'rich' }, blocks: [{ _tag: 'text', text: prompt }] }),
52
+ ]);
53
+
54
+ const reply = Message.make({ sender: { role: 'assistant', name: 'Assistant' }, blocks: [] });
55
+ model.append([reply]);
56
+ model.setStreaming(reply.id);
57
+ try {
58
+ for await (const blocks of streamTurn({ wordsPerChunk, chunkDelay })) {
59
+ // Patched, not mutated: a schema-made message is frozen, so each chunk is a fresh value
60
+ // under the same identity — which is also what an item needs to reconcile by delta.
61
+ model.patch(reply.id, { ...reply, blocks } as Message.Message);
62
+ }
63
+ } finally {
64
+ model.setStreaming(undefined);
65
+ setBusy(false);
66
+ }
67
+ },
68
+ [model, wordsPerChunk, chunkDelay],
69
+ );
70
+
71
+ // The thread's outward events: a suggestion or select click is a submit; the prompt toolbar's
72
+ // rewind truncates the thread back to that prompt — the host shapes both, which is the point.
73
+ const handleEvent = useCallback(
74
+ (event: ChatThreadEvent) => {
75
+ switch (event.type) {
76
+ case 'submit': {
77
+ if (!autoRef.current && !busy) {
78
+ void answer(event.text);
79
+ }
80
+ break;
81
+ }
82
+ case 'rewind': {
83
+ const index = model.indexOf(event.id);
84
+ if (index >= 0) {
85
+ model.replace(model.messages.slice(0, index));
86
+ }
87
+ break;
88
+ }
89
+ }
90
+ },
91
+ [model, answer, busy],
92
+ );
93
+
94
+ // Hands-off mode for review: turns keep arriving until stopped, as an unbounded conversation.
95
+ const toggleAuto = useCallback(() => {
96
+ const next = !autoRef.current;
97
+ autoRef.current = next;
98
+ setAuto(next);
99
+ if (next) {
100
+ void (async () => {
101
+ let turn = 0;
102
+ while (autoRef.current) {
103
+ await answer(`Question ${++turn}: what changed since the last answer?`);
104
+ await new Promise((resolve) => setTimeout(resolve, 800));
105
+ }
106
+ })();
107
+ }
108
+ }, [answer]);
109
+
110
+ return (
111
+ <DebugProvider>
112
+ <ChatThread.Root model={model} viewType={viewType} onEvent={handleEvent}>
113
+ <Panel.Root>
114
+ <Panel.Toolbar asChild>
115
+ <Toolbar.Root>
116
+ <IconButton
117
+ icon={auto ? 'ph--stop--regular' : 'ph--play--regular'}
118
+ iconOnly
119
+ label={auto ? 'Stop the agent' : 'Let the agent talk'}
120
+ data-testid='assistant.auto'
121
+ onClick={toggleAuto}
122
+ />
123
+ <Toolbar.Separator />
124
+ <MessageList.Nav ends={false} classNames='contents' />
125
+ </Toolbar.Root>
126
+ </Panel.Toolbar>
127
+
128
+ <Panel.Content classNames='dx-container flex flex-col min-h-0'>
129
+ <div className='relative grow min-h-0'>
130
+ <PromptOutline model={model} />
131
+ <ChatThread.Viewport classNames='absolute inset-0' padding />
132
+ {debug && <Probes model={model} />}
133
+ </div>
134
+ <PromptInput busy={busy} onSubmit={(prompt) => void answer(prompt)} />
135
+ </Panel.Content>
136
+ </Panel.Root>
137
+ {debug && <Debug />}
138
+ </ChatThread.Root>
139
+ </DebugProvider>
140
+ );
141
+ };
142
+
143
+ /** The prompt box: the reader's half of the conversation. */
144
+ const PromptInput = ({ busy, onSubmit }: { busy: boolean; onSubmit: (prompt: string) => void }) => {
145
+ const [prompt, setPrompt] = useState('');
146
+ const submit = () => {
147
+ const text = prompt.trim();
148
+ if (text.length) {
149
+ onSubmit(text);
150
+ setPrompt('');
151
+ }
152
+ };
153
+
154
+ return (
155
+ <div className='p-2'>
156
+ <Input.Root>
157
+ <Input.TextInput
158
+ placeholder={busy ? 'Answering…' : 'Ask something…'}
159
+ value={prompt}
160
+ data-testid='assistant.prompt'
161
+ onChange={(event) => setPrompt(event.target.value)}
162
+ onKeyDown={(event) => event.key === 'Enter' && submit()}
163
+ />
164
+ </Input.Root>
165
+ </div>
166
+ );
167
+ };
168
+
169
+ /** The outline rail over the model's own stops, stepping through the same seam as everything else. */
170
+ const PromptOutline = ({ classNames, model }: { classNames?: string; model: FeedModel }) => {
171
+ const { currentIndex, navigation, count } = useMessageList('PromptOutline');
172
+
173
+ const markers = useMemo<OutlineMarker[]>(
174
+ () =>
175
+ model.stops().map(({ index, id }) => ({
176
+ id,
177
+ title: `#${index}`,
178
+ description: model.textOf(id)?.slice(0, 160),
179
+ range: { from: index, to: index + 1 },
180
+ })),
181
+ // Recomputed per count change: stops are a policy over the model.
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ [model, count],
184
+ );
185
+
186
+ const current = useMemo(() => {
187
+ const marker = [...markers].reverse().find(({ range: { from } }) => from <= currentIndex) ?? markers[0];
188
+ return marker?.range;
189
+ }, [markers, currentIndex]);
190
+
191
+ if (!markers.length) {
192
+ return <div />;
193
+ }
194
+
195
+ return (
196
+ <div className='z-10 absolute left-0 top-0 bottom-0 grid grid-rows-[1fr_4fr_1fr] justify-center'>
197
+ <Outline
198
+ classNames={['row-start-2 justify-self-center', classNames]}
199
+ markers={markers}
200
+ visibleRange={current}
201
+ onSelect={(marker) => navigation.jumpTo(marker.range.from, 'smooth')}
202
+ onNavigate={(delta) => navigation.step(delta)}
203
+ />
204
+ </div>
205
+ );
206
+ };
207
+
208
+ /** Probes over every aspect, registered into the Debug table for as long as they are mounted. */
209
+ const Probes = ({ model }: { model: FeedModel }) => {
210
+ const meter = useFrameMeter();
211
+ const { range, mountedRows, mountedWidgets, jumps } = useMessageList('Probes');
212
+ const state = useRef({ range, mountedRows, mountedWidgets, jumps, model });
213
+ state.current = { range, mountedRows, mountedWidgets, jumps, model };
214
+
215
+ useDebugProbes(() => [
216
+ {
217
+ id: 'fps',
218
+ group: 'frames',
219
+ read: () => Math.round(meter.fps),
220
+ alarm: (value) => Number(value) < 30,
221
+ },
222
+ {
223
+ id: 'worst',
224
+ group: 'frames',
225
+ unit: 'ms',
226
+ read: () => Math.round(meter.worst),
227
+ },
228
+ {
229
+ id: 'count',
230
+ group: 'model',
231
+ read: () => state.current.model.count,
232
+ },
233
+ {
234
+ id: 'streaming',
235
+ group: 'model',
236
+ read: () => (state.current.model.streamingId ? 'yes' : '—'),
237
+ },
238
+ {
239
+ id: 'range',
240
+ group: 'window',
241
+ read: () => {
242
+ const { range } = state.current;
243
+ return range ? `${range.startIndex}–${range.endIndex}` : '—';
244
+ },
245
+ },
246
+ {
247
+ id: 'mounted',
248
+ group: 'window',
249
+ read: () => state.current.mountedRows,
250
+ },
251
+ {
252
+ id: 'widgets',
253
+ group: 'window',
254
+ read: () => state.current.mountedWidgets,
255
+ },
256
+ {
257
+ id: 'jumps',
258
+ group: 'window',
259
+ read: () => state.current.jumps.count,
260
+ alarm: (value) => Number(value) > 0,
261
+ },
262
+ ]);
263
+
264
+ return null;
265
+ };
266
+
267
+ const meta: Meta<StoryArgs> = {
268
+ title: 'ui/react-ui-assistant/ChatThread',
269
+ render: DefaultStory,
270
+ decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
271
+ parameters: { layout: 'fullscreen', translations },
272
+ argTypes: {
273
+ viewType: { control: 'select', options: ['normal', 'summary', 'thinking', 'debug'] },
274
+ debug: { control: 'boolean' },
275
+ },
276
+ args: {
277
+ count: 30,
278
+ viewType: 'thinking',
279
+ debug: true,
280
+ },
281
+ };
282
+
283
+ export default meta;
284
+
285
+ type Story = StoryObj<StoryArgs>;
286
+
287
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
288
+
289
+ const settle = async (frames = 30) => {
290
+ for (let frame = 0; frame < frames; frame++) {
291
+ await nextFrame();
292
+ }
293
+ };
294
+
295
+ /** Reflects a typed value into a React-controlled input: the native setter, then the event. */
296
+ const type = (input: HTMLInputElement, value: string) => {
297
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
298
+ setter.call(input, value);
299
+ input.dispatchEvent(new Event('input', { bubbles: true }));
300
+ };
301
+
302
+ /** The loop, hands on: type a prompt, or press ▶ and watch. No play — this one is for people. */
303
+ export const Default: Story = {};
304
+
305
+ /**
306
+ * The whole conversation loop, driven as a reader would drive it.
307
+ *
308
+ * A prompt is typed and entered; the agent answers in blocks; the tail stays at rest through the
309
+ * stream (the follow at work); scrolling up mid-answer withdraws the follow and the feed holds
310
+ * still under a growing document.
311
+ */
312
+ export const Assistant: Story = {
313
+ args: { chunkDelay: 40 },
314
+ play: async ({ canvasElement }) => {
315
+ await settle(40);
316
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="feed.viewport"]')!;
317
+ const atTail = () => {
318
+ const rows = [...scroller.querySelectorAll<HTMLElement>('[data-index]')];
319
+ const last = rows[rows.length - 1];
320
+ return !!last && Math.abs(last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom) <= 2;
321
+ };
322
+ const countRows = () => scroller.querySelectorAll('[data-index]').length > 0;
323
+
324
+ // 1. The reader asks. "Asked" is read from what a reader would see: the input cleared on
325
+ // submit, and the turn started (the placeholder flips while the agent answers) — never from
326
+ // mounted-row counts, which *shrink* as taller streamed rows fill the window.
327
+ const input = canvasElement.querySelector<HTMLInputElement>('[data-testid="assistant.prompt"]')!;
328
+ type(input, 'What is an anchor-relative placement?');
329
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
330
+ await settle(10);
331
+ const asked = input.value === '';
332
+
333
+ // 2. The answer streams in blocks while the follow keeps the tail near rest. The glide is
334
+ // allowed to lag a growing tail by a bounded distance — that is what travelling means — so the
335
+ // reading is the worst lag during the stream, and exact rest once the turn lands.
336
+ const lag = () => {
337
+ const rows = [...scroller.querySelectorAll<HTMLElement>('[data-index]')];
338
+ const last = rows[rows.length - 1];
339
+ return last ? last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom : 0;
340
+ };
341
+
342
+ let sawStream = false;
343
+ let worst = 0;
344
+ for (let frame = 0; frame < 600; frame++) {
345
+ await nextFrame();
346
+ sawStream = sawStream || atTail();
347
+ worst = Math.max(worst, lag());
348
+ // The turn is over when the input re-enables.
349
+ if (sawStream && input.placeholder !== 'Answering…') {
350
+ break;
351
+ }
352
+ }
353
+
354
+ // Landed: arrival is by deceleration, so rest is polled rather than sampled.
355
+ let rests = false;
356
+ for (let frame = 0; frame < 300 && !rests; frame++) {
357
+ await nextFrame();
358
+ rests = atTail();
359
+ }
360
+
361
+ await expect({
362
+ asked,
363
+ sawStream,
364
+ bounded: worst < scroller.clientHeight,
365
+ rests,
366
+ mounted: countRows(),
367
+ }).toEqual({
368
+ asked: true,
369
+ sawStream: true,
370
+ bounded: true,
371
+ rests: true,
372
+ mounted: true,
373
+ });
374
+ },
375
+ };
376
+
377
+ /**
378
+ * Scrolling away mid-answer stops the follow, and it stays stopped: a reader looking at history is
379
+ * not dragged back by the stream. Returning to the tail opts back in.
380
+ */
381
+ export const Interrupted: Story = {
382
+ args: { chunkDelay: 40, count: 60 },
383
+ play: async ({ canvasElement }) => {
384
+ await settle(40);
385
+ const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="feed.viewport"]')!;
386
+
387
+ // Start a hands-off turn, then leave for history mid-stream.
388
+ (canvasElement.querySelector('[data-testid="assistant.auto"]') as HTMLElement).click();
389
+ await settle(30);
390
+ // The wheel first: a scroll the follow cannot attribute to the reader is one it will undo.
391
+ scroller.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }));
392
+ scroller.scrollTop = Math.max(0, scroller.scrollTop - 3 * scroller.clientHeight);
393
+ await settle(10);
394
+
395
+ const away = scroller.scrollTop;
396
+ await settle(60);
397
+ const stayed = Math.abs(scroller.scrollTop - away) <= 2;
398
+
399
+ // Returning to the tail opts back in: the next answer keeps it at rest. Without this half the
400
+ // story passes with no follow at all — "stays away" is trivially true then.
401
+ let previous = -1;
402
+ for (let attempt = 0; attempt < 8 && scroller.scrollTop !== previous; attempt++) {
403
+ previous = scroller.scrollTop;
404
+ scroller.scrollTop = scroller.scrollHeight;
405
+ await settle(10);
406
+ }
407
+
408
+ let resumed = false;
409
+ for (let frame = 0; frame < 400 && !resumed; frame++) {
410
+ await nextFrame();
411
+ const rows = [...scroller.querySelectorAll<HTMLElement>('[data-index]')];
412
+ const last = rows[rows.length - 1];
413
+ resumed = !!last && Math.abs(last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom) <= 2;
414
+ }
415
+
416
+ (canvasElement.querySelector('[data-testid="assistant.auto"]') as HTMLElement).click();
417
+ await expect({ stayed, resumed }).toEqual({ stayed: true, resumed: true });
418
+ },
419
+ };
@@ -0,0 +1,188 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { createContext } from '@radix-ui/react-context';
6
+ import React, {
7
+ type ComponentPropsWithoutRef,
8
+ type PropsWithChildren,
9
+ type Ref,
10
+ useCallback,
11
+ useEffect,
12
+ useMemo,
13
+ } from 'react';
14
+
15
+ import { setRef } from '@dxos/react-ui';
16
+ import {
17
+ type FeedModel,
18
+ type FeedNavigation,
19
+ MessageList,
20
+ type MessageRange,
21
+ type ScrollToOptions,
22
+ useMessageList,
23
+ } from '@dxos/react-ui-feed';
24
+ import { type XmlWidgetRegistry } from '@dxos/ui-editor';
25
+
26
+ import { assistantRegistry } from '../../registry';
27
+ import { type CreateRendererOptions, createRenderer, estimateRow } from '../../renderer';
28
+ import { type ChatThreadEvent, type ChatView } from '../../types';
29
+ import { MessageChrome, MessageChromeProvider } from '../MessageChrome';
30
+
31
+ //
32
+ // Context
33
+ //
34
+
35
+ const CHAT_THREAD_NAME = 'ChatThread';
36
+
37
+ type ChatThreadContextValue = {
38
+ userHue?: string;
39
+ onEvent?: (event: ChatThreadEvent) => void;
40
+ };
41
+
42
+ const [ChatThreadProvider, useChatThreadContext] = createContext<ChatThreadContextValue>(CHAT_THREAD_NAME);
43
+
44
+ //
45
+ // Controller
46
+ //
47
+
48
+ /**
49
+ * The imperative handle a host drives the thread through — the successor of the old
50
+ * `MarkdownStreamController`, shrunk to what a message-indexed feed still needs: everything else
51
+ * (visible range, spans, widget state) is either context or the model's own business.
52
+ */
53
+ export type ChatThreadController = {
54
+ model: FeedModel;
55
+ scrollToBottom: (options?: ScrollToOptions) => void;
56
+ scrollToIndex: (index: number, options?: ScrollToOptions) => void;
57
+ /** The one seam every navigation driver shares: toolbar, arrows, rails. */
58
+ navigation: FeedNavigation;
59
+ };
60
+
61
+ /** Bridges the feed's context out to the host's ref; renders nothing. */
62
+ const ControllerBridge = ({ controllerRef }: { controllerRef: Ref<ChatThreadController> }) => {
63
+ const { model, scrollToBottom, scrollToIndex, navigation } = useMessageList('ChatThread.Controller');
64
+ useEffect(() => {
65
+ setRef(controllerRef, { model, scrollToBottom, scrollToIndex, navigation });
66
+ return () => {
67
+ setRef(controllerRef, null);
68
+ };
69
+ }, [controllerRef, model, scrollToBottom, scrollToIndex, navigation]);
70
+
71
+ return null;
72
+ };
73
+
74
+ //
75
+ // Root
76
+ //
77
+
78
+ type ChatThreadRootProps = PropsWithChildren<
79
+ CreateRendererOptions & {
80
+ /** The thread's model. `useFeedModel(messages, { stops: 'prompt' })` adapts an array host. */
81
+ model: FeedModel;
82
+ viewType?: ChatView;
83
+ /** Extends {@link assistantRegistry}; the host's entries win (e.g. a real `surface` widget). */
84
+ registry?: XmlWidgetRegistry;
85
+ /** The reader's identity hue, published to the DOM for the prompt frame's tokens. */
86
+ userHue?: string;
87
+ debug?: boolean;
88
+ onEvent?: (event: ChatThreadEvent) => void;
89
+ /** The visible index range, as the reader scrolls — what an outline rail tracks. */
90
+ onRangeChange?: (range: MessageRange) => void;
91
+ controllerRef?: Ref<ChatThreadController>;
92
+ }
93
+ >;
94
+
95
+ /**
96
+ * Headless root: the feed's `MessageList.Root` configured as an assistant thread — the view-typed
97
+ * renderer, the widget registry, the prompt/answer chrome, and the chat behaviours (follow the
98
+ * streaming tail; reserve room to bring the last prompt to the top). Composes with the feed's own
99
+ * parts: `MessageList.Nav`, `useMessageList`, and the rails all work inside it.
100
+ */
101
+ const ChatThreadRoot = ({
102
+ children,
103
+ model,
104
+ viewType,
105
+ registry,
106
+ getObjectLabel,
107
+ userHue,
108
+ debug,
109
+ onEvent,
110
+ onRangeChange,
111
+ controllerRef,
112
+ }: ChatThreadRootProps) => {
113
+ const renderer = useMemo(() => createRenderer(viewType, { getObjectLabel }), [viewType, getObjectLabel]);
114
+ const merged = useMemo(() => (registry ? { ...assistantRegistry, ...registry } : assistantRegistry), [registry]);
115
+ const handleRewind = useCallback((id: string) => onEvent?.({ type: 'rewind', id }), [onEvent]);
116
+
117
+ return (
118
+ <ChatThreadProvider userHue={userHue} onEvent={onEvent}>
119
+ <MessageChromeProvider onRewind={onEvent ? handleRewind : undefined}>
120
+ <MessageList.Root
121
+ model={model}
122
+ renderer={renderer}
123
+ registry={merged}
124
+ Chrome={MessageChrome}
125
+ estimateSize={estimateRow}
126
+ debug={debug}
127
+ stickyBottom
128
+ scrollPastEnd
129
+ onRangeChange={onRangeChange}
130
+ >
131
+ {controllerRef && <ControllerBridge controllerRef={controllerRef} />}
132
+ {children}
133
+ </MessageList.Root>
134
+ </MessageChromeProvider>
135
+ </ChatThreadProvider>
136
+ );
137
+ };
138
+
139
+ ChatThreadRoot.displayName = 'ChatThread.Root';
140
+
141
+ //
142
+ // Viewport
143
+ //
144
+
145
+ const CHAT_THREAD_VIEWPORT_NAME = 'ChatThread.Viewport';
146
+
147
+ type ChatThreadViewportProps = ComponentPropsWithoutRef<typeof MessageList.Viewport>;
148
+
149
+ /**
150
+ * The scrolling thread. Suggestion and select widgets are DOM widgets carrying
151
+ * `data-action="submit"` buttons; one delegated listener here turns those clicks into `submit`
152
+ * events, which is what keeps the widgets renderable from the tag alone.
153
+ */
154
+ const ChatThreadViewport = ({ children, ...props }: ChatThreadViewportProps) => {
155
+ const { userHue, onEvent } = useChatThreadContext(CHAT_THREAD_VIEWPORT_NAME);
156
+
157
+ const handleClick = useCallback(
158
+ (event: React.MouseEvent) => {
159
+ const action = (event.target as HTMLElement).closest<HTMLElement>('[data-action="submit"]');
160
+ const text = action?.getAttribute('data-value');
161
+ if (text) {
162
+ event.preventDefault();
163
+ event.stopPropagation();
164
+ onEvent?.({ type: 'submit', text });
165
+ }
166
+ },
167
+ [onEvent],
168
+ );
169
+
170
+ return (
171
+ <div className='contents' data-testid='assistant.thread' data-hue={userHue} onClickCapture={handleClick}>
172
+ <MessageList.Viewport {...props}>{children}</MessageList.Viewport>
173
+ </div>
174
+ );
175
+ };
176
+
177
+ ChatThreadViewport.displayName = CHAT_THREAD_VIEWPORT_NAME;
178
+
179
+ //
180
+ // ChatThread
181
+ //
182
+
183
+ export const ChatThread = {
184
+ Root: ChatThreadRoot,
185
+ Viewport: ChatThreadViewport,
186
+ };
187
+
188
+ export type { ChatThreadRootProps, ChatThreadViewportProps };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './ChatThread';