@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.
- package/LICENSE +105 -0
- package/dist/lib/index.mjs +839 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +214 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/components/ChatThread/ChatThread.d.ts +52 -0
- package/dist/types/src/components/ChatThread/ChatThread.d.ts.map +1 -0
- package/dist/types/src/components/ChatThread/ChatThread.stories.d.ts +38 -0
- package/dist/types/src/components/ChatThread/ChatThread.stories.d.ts.map +1 -0
- package/dist/types/src/components/ChatThread/index.d.ts +2 -0
- package/dist/types/src/components/ChatThread/index.d.ts.map +1 -0
- package/dist/types/src/components/MessageChrome/MessageChrome.d.ts +28 -0
- package/dist/types/src/components/MessageChrome/MessageChrome.d.ts.map +1 -0
- package/dist/types/src/components/MessageChrome/index.d.ts +2 -0
- package/dist/types/src/components/MessageChrome/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +4 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/components/widgets/FallbackWidget.d.ts +7 -0
- package/dist/types/src/components/widgets/FallbackWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ReasoningWidget.d.ts +25 -0
- package/dist/types/src/components/widgets/ReasoningWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ReasoningWidget.stories.d.ts +18 -0
- package/dist/types/src/components/widgets/ReasoningWidget.stories.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ReferenceWidget.d.ts +9 -0
- package/dist/types/src/components/widgets/ReferenceWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/SelectWidget.d.ts +14 -0
- package/dist/types/src/components/widgets/SelectWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/StatsWidget.d.ts +12 -0
- package/dist/types/src/components/widgets/StatsWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/StatusWidget.d.ts +12 -0
- package/dist/types/src/components/widgets/StatusWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/SuggestionWidget.d.ts +11 -0
- package/dist/types/src/components/widgets/SuggestionWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/SummaryWidget.d.ts +4 -0
- package/dist/types/src/components/widgets/SummaryWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ToolWidget.d.ts +10 -0
- package/dist/types/src/components/widgets/ToolWidget.d.ts.map +1 -0
- package/dist/types/src/components/widgets/ToolWidget.stories.d.ts +31 -0
- package/dist/types/src/components/widgets/ToolWidget.stories.d.ts.map +1 -0
- package/dist/types/src/components/widgets/defaults.d.ts +11 -0
- package/dist/types/src/components/widgets/defaults.d.ts.map +1 -0
- package/dist/types/src/components/widgets/format-time.d.ts +2 -0
- package/dist/types/src/components/widgets/format-time.d.ts.map +1 -0
- package/dist/types/src/components/widgets/index.d.ts +11 -0
- package/dist/types/src/components/widgets/index.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/registry.d.ts +11 -0
- package/dist/types/src/registry.d.ts.map +1 -0
- package/dist/types/src/renderer.d.ts +25 -0
- package/dist/types/src/renderer.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/test-generator.d.ts +7 -0
- package/dist/types/src/testing/test-generator.d.ts.map +1 -0
- package/dist/types/src/testing/test-generator.test.d.ts +2 -0
- package/dist/types/src/testing/test-generator.test.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +19 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +28 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +67 -0
- package/src/components/ChatThread/ChatThread.stories.tsx +419 -0
- package/src/components/ChatThread/ChatThread.tsx +188 -0
- package/src/components/ChatThread/index.ts +5 -0
- package/src/components/MessageChrome/MessageChrome.tsx +147 -0
- package/src/components/MessageChrome/index.ts +5 -0
- package/src/components/index.ts +7 -0
- package/src/components/widgets/FallbackWidget.tsx +26 -0
- package/src/components/widgets/ReasoningWidget.stories.tsx +68 -0
- package/src/components/widgets/ReasoningWidget.ts +139 -0
- package/src/components/widgets/ReferenceWidget.ts +28 -0
- package/src/components/widgets/SelectWidget.ts +39 -0
- package/src/components/widgets/StatsWidget.ts +31 -0
- package/src/components/widgets/StatusWidget.ts +53 -0
- package/src/components/widgets/SuggestionWidget.ts +50 -0
- package/src/components/widgets/SummaryWidget.tsx +27 -0
- package/src/components/widgets/ToolWidget.stories.tsx +55 -0
- package/src/components/widgets/ToolWidget.tsx +185 -0
- package/src/components/widgets/defaults.ts +14 -0
- package/src/components/widgets/format-time.ts +45 -0
- package/src/components/widgets/index.ts +14 -0
- package/src/index.ts +9 -0
- package/src/registry.tsx +144 -0
- package/src/renderer.ts +216 -0
- package/src/testing/index.ts +5 -0
- package/src/testing/test-generator.test.ts +75 -0
- package/src/testing/test-generator.ts +280 -0
- package/src/translations.ts +27 -0
- 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 };
|