@dxos/react-ui-feed 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 (230) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/chunk-src.mjs +2228 -0
  3. package/dist/lib/chunk-src.mjs.map +1 -0
  4. package/dist/lib/index.mjs +2 -0
  5. package/dist/lib/testing.mjs +1923 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
  8. package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
  9. package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
  10. package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
  11. package/dist/types/src/components/Block/block.stories.d.ts +21 -0
  12. package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
  13. package/dist/types/src/components/Block/construction.d.ts +36 -0
  14. package/dist/types/src/components/Block/construction.d.ts.map +1 -0
  15. package/dist/types/src/components/Block/extensions.d.ts +23 -0
  16. package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
  17. package/dist/types/src/components/Block/highlight.d.ts +14 -0
  18. package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
  19. package/dist/types/src/components/Block/index.d.ts +6 -0
  20. package/dist/types/src/components/Block/index.d.ts.map +1 -0
  21. package/dist/types/src/components/Block/selection-group.d.ts +21 -0
  22. package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
  23. package/dist/types/src/components/Block/widget-state.d.ts +33 -0
  24. package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
  25. package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
  26. package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
  27. package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
  28. package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
  29. package/dist/types/src/components/MessageList/index.d.ts +3 -0
  30. package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
  31. package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
  32. package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
  33. package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
  34. package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
  35. package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
  36. package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
  37. package/dist/types/src/components/Minimap/index.d.ts +2 -0
  38. package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Outline/Outline.d.ts +46 -0
  40. package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
  41. package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
  42. package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/Outline/index.d.ts +2 -0
  44. package/dist/types/src/components/Outline/index.d.ts.map +1 -0
  45. package/dist/types/src/components/index.d.ts +5 -0
  46. package/dist/types/src/components/index.d.ts.map +1 -0
  47. package/dist/types/src/debug/Debug.d.ts +17 -0
  48. package/dist/types/src/debug/Debug.d.ts.map +1 -0
  49. package/dist/types/src/debug/Stats.d.ts +30 -0
  50. package/dist/types/src/debug/Stats.d.ts.map +1 -0
  51. package/dist/types/src/debug/debug-model.d.ts +32 -0
  52. package/dist/types/src/debug/debug-model.d.ts.map +1 -0
  53. package/dist/types/src/debug/debug-model.test.d.ts +2 -0
  54. package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
  55. package/dist/types/src/debug/frame-meter.d.ts +48 -0
  56. package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
  57. package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
  58. package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
  59. package/dist/types/src/debug/index.d.ts +6 -0
  60. package/dist/types/src/debug/index.d.ts.map +1 -0
  61. package/dist/types/src/debug/use-debug.d.ts +19 -0
  62. package/dist/types/src/debug/use-debug.d.ts.map +1 -0
  63. package/dist/types/src/hooks/follow.d.ts +89 -0
  64. package/dist/types/src/hooks/follow.d.ts.map +1 -0
  65. package/dist/types/src/hooks/follow.stories.d.ts +25 -0
  66. package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
  67. package/dist/types/src/hooks/follow.test.d.ts +2 -0
  68. package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
  69. package/dist/types/src/hooks/index.d.ts +6 -0
  70. package/dist/types/src/hooks/index.d.ts.map +1 -0
  71. package/dist/types/src/hooks/useDecorations.d.ts +31 -0
  72. package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
  73. package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
  74. package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useFollow.d.ts +46 -0
  76. package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
  77. package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
  78. package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/index.d.ts.map +1 -0
  81. package/dist/types/src/model/feed-model.d.ts +67 -0
  82. package/dist/types/src/model/feed-model.d.ts.map +1 -0
  83. package/dist/types/src/model/feed-model.test.d.ts +2 -0
  84. package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
  85. package/dist/types/src/model/feed.d.ts +66 -0
  86. package/dist/types/src/model/feed.d.ts.map +1 -0
  87. package/dist/types/src/model/feed.test.d.ts +2 -0
  88. package/dist/types/src/model/feed.test.d.ts.map +1 -0
  89. package/dist/types/src/model/index.d.ts +5 -0
  90. package/dist/types/src/model/index.d.ts.map +1 -0
  91. package/dist/types/src/model/list-model.d.ts +61 -0
  92. package/dist/types/src/model/list-model.d.ts.map +1 -0
  93. package/dist/types/src/model/list-model.test.d.ts +2 -0
  94. package/dist/types/src/model/list-model.test.d.ts.map +1 -0
  95. package/dist/types/src/model/useListModel.d.ts +22 -0
  96. package/dist/types/src/model/useListModel.d.ts.map +1 -0
  97. package/dist/types/src/stories/assistant.stories.d.ts +38 -0
  98. package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
  99. package/dist/types/src/stories/baseline.stories.d.ts +32 -0
  100. package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
  101. package/dist/types/src/stories/bridge.stories.d.ts +72 -0
  102. package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
  103. package/dist/types/src/stories/construction.stories.d.ts +17 -0
  104. package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
  105. package/dist/types/src/stories/fill.stories.d.ts +51 -0
  106. package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
  107. package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
  108. package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
  109. package/dist/types/src/stories/mount.stories.d.ts +19 -0
  110. package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
  111. package/dist/types/src/stories/navigation.stories.d.ts +33 -0
  112. package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
  113. package/dist/types/src/stories/streaming.stories.d.ts +17 -0
  114. package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
  115. package/dist/types/src/stories/tail.stories.d.ts +47 -0
  116. package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
  117. package/dist/types/src/stories/widget.stories.d.ts +16 -0
  118. package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
  119. package/dist/types/src/testing/FeedStory.d.ts +30 -0
  120. package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
  121. package/dist/types/src/testing/MessageWindow.d.ts +48 -0
  122. package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
  123. package/dist/types/src/testing/controls.d.ts +46 -0
  124. package/dist/types/src/testing/controls.d.ts.map +1 -0
  125. package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
  126. package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
  127. package/dist/types/src/testing/debug/index.d.ts +4 -0
  128. package/dist/types/src/testing/debug/index.d.ts.map +1 -0
  129. package/dist/types/src/testing/debug/sweep.d.ts +18 -0
  130. package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
  131. package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
  132. package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
  133. package/dist/types/src/testing/generator.d.ts +16 -0
  134. package/dist/types/src/testing/generator.d.ts.map +1 -0
  135. package/dist/types/src/testing/index.d.ts +10 -0
  136. package/dist/types/src/testing/index.d.ts.map +1 -0
  137. package/dist/types/src/testing/scenarios.d.ts +58 -0
  138. package/dist/types/src/testing/scenarios.d.ts.map +1 -0
  139. package/dist/types/src/testing/stream.d.ts +22 -0
  140. package/dist/types/src/testing/stream.d.ts.map +1 -0
  141. package/dist/types/src/testing/turn.d.ts +21 -0
  142. package/dist/types/src/testing/turn.d.ts.map +1 -0
  143. package/dist/types/src/testing/widgets.d.ts +3 -0
  144. package/dist/types/src/testing/widgets.d.ts.map +1 -0
  145. package/dist/types/src/virtualizer/Window.d.ts +137 -0
  146. package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
  147. package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
  148. package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
  149. package/dist/types/src/virtualizer/index.d.ts +3 -0
  150. package/dist/types/src/virtualizer/index.d.ts.map +1 -0
  151. package/dist/types/src/virtualizer/placement.d.ts +188 -0
  152. package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
  153. package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
  154. package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
  155. package/dist/types/tsconfig.tsbuildinfo +1 -0
  156. package/package.json +72 -0
  157. package/src/components/Block/HtmlBlock.tsx +26 -0
  158. package/src/components/Block/MarkdownBlock.tsx +157 -0
  159. package/src/components/Block/block.stories.tsx +140 -0
  160. package/src/components/Block/construction.ts +77 -0
  161. package/src/components/Block/extensions.ts +95 -0
  162. package/src/components/Block/highlight.ts +53 -0
  163. package/src/components/Block/index.ts +10 -0
  164. package/src/components/Block/selection-group.ts +53 -0
  165. package/src/components/Block/widget-state.tsx +75 -0
  166. package/src/components/MessageList/MessageList.stories.tsx +111 -0
  167. package/src/components/MessageList/MessageList.tsx +647 -0
  168. package/src/components/MessageList/index.ts +6 -0
  169. package/src/components/MessageList/position-log.ts +167 -0
  170. package/src/components/Minimap/Minimap.stories.tsx +129 -0
  171. package/src/components/Minimap/Minimap.tsx +84 -0
  172. package/src/components/Minimap/index.ts +5 -0
  173. package/src/components/Outline/Outline.stories.tsx +196 -0
  174. package/src/components/Outline/Outline.tsx +383 -0
  175. package/src/components/Outline/index.ts +5 -0
  176. package/src/components/index.ts +8 -0
  177. package/src/debug/Debug.tsx +90 -0
  178. package/src/debug/Stats.tsx +57 -0
  179. package/src/debug/debug-model.test.ts +36 -0
  180. package/src/debug/debug-model.ts +66 -0
  181. package/src/debug/frame-meter.test.ts +49 -0
  182. package/src/debug/frame-meter.ts +188 -0
  183. package/src/debug/index.ts +9 -0
  184. package/src/debug/use-debug.tsx +41 -0
  185. package/src/hooks/follow.stories.tsx +200 -0
  186. package/src/hooks/follow.test.ts +105 -0
  187. package/src/hooks/follow.ts +241 -0
  188. package/src/hooks/index.ts +9 -0
  189. package/src/hooks/useDecorations.tsx +54 -0
  190. package/src/hooks/useFeedNavigation.ts +90 -0
  191. package/src/hooks/useFollow.ts +258 -0
  192. package/src/hooks/useItemSelection.tsx +53 -0
  193. package/src/index.ts +7 -0
  194. package/src/model/feed-model.test.ts +84 -0
  195. package/src/model/feed-model.ts +214 -0
  196. package/src/model/feed.test.ts +65 -0
  197. package/src/model/feed.ts +157 -0
  198. package/src/model/index.ts +8 -0
  199. package/src/model/list-model.test.ts +71 -0
  200. package/src/model/list-model.ts +152 -0
  201. package/src/model/useListModel.ts +53 -0
  202. package/src/stories/assistant.stories.tsx +425 -0
  203. package/src/stories/baseline.stories.tsx +78 -0
  204. package/src/stories/bridge.stories.tsx +376 -0
  205. package/src/stories/construction.stories.tsx +147 -0
  206. package/src/stories/fill.stories.tsx +271 -0
  207. package/src/stories/horizontal.stories.tsx +129 -0
  208. package/src/stories/mount.stories.tsx +233 -0
  209. package/src/stories/navigation.stories.tsx +145 -0
  210. package/src/stories/streaming.stories.tsx +132 -0
  211. package/src/stories/tail.stories.tsx +149 -0
  212. package/src/stories/widget.stories.tsx +107 -0
  213. package/src/testing/FeedStory.tsx +452 -0
  214. package/src/testing/MessageWindow.tsx +139 -0
  215. package/src/testing/controls.tsx +96 -0
  216. package/src/testing/debug/FeedStats.tsx +182 -0
  217. package/src/testing/debug/index.ts +16 -0
  218. package/src/testing/debug/sweep.ts +85 -0
  219. package/src/testing/debug/use-feed-debug.ts +91 -0
  220. package/src/testing/generator.ts +90 -0
  221. package/src/testing/index.ts +13 -0
  222. package/src/testing/scenarios.tsx +552 -0
  223. package/src/testing/stream.ts +70 -0
  224. package/src/testing/turn.ts +91 -0
  225. package/src/testing/widgets.tsx +183 -0
  226. package/src/virtualizer/Window.stories.tsx +683 -0
  227. package/src/virtualizer/Window.tsx +435 -0
  228. package/src/virtualizer/index.ts +6 -0
  229. package/src/virtualizer/placement.test.ts +312 -0
  230. package/src/virtualizer/placement.ts +403 -0
@@ -0,0 +1,552 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { type ComponentType } from 'react';
6
+
7
+ import { random } from '@dxos/random';
8
+ import { IconButton } from '@dxos/react-ui';
9
+ import { type ContentBlock, Message } from '@dxos/types';
10
+ import { type XmlWidgetRegistry } from '@dxos/ui-editor';
11
+ import { mx } from '@dxos/ui-theme';
12
+
13
+ import { type MessageChromeProps, type MessageRenderer, chatRenderer, defaultRenderer } from '../';
14
+ import {
15
+ BareEditorItem,
16
+ DecoratedEditorItem,
17
+ MarkdownEditorItem,
18
+ MarkdownProbeItem,
19
+ TextItem,
20
+ ThemedEditorItem,
21
+ } from './controls';
22
+ import { chatRegistry } from './widgets';
23
+
24
+ const UNIFORM_CONTROLS = {
25
+ 'uniform-text': TextItem,
26
+ 'uniform-bare': BareEditorItem,
27
+ 'uniform-themed': ThemedEditorItem,
28
+ 'uniform-markdown': MarkdownEditorItem,
29
+ 'uniform-decorated': DecoratedEditorItem,
30
+ 'uniform-item': MarkdownProbeItem,
31
+ } as const;
32
+
33
+ /**
34
+ * The five places in the repo that render a thread of messages, approximated against one engine.
35
+ *
36
+ * The point is not the fixtures — it is that all five differ only in the renderer, the chrome and a
37
+ * couple of options, while the list, the virtualization, the selection and the search are the same
38
+ * code. Where a scenario needs something the engine does not have, that is the finding.
39
+ */
40
+ export type FeedScenario =
41
+ /** No editor at all: fixed-height divs, so the list is measured against content that cannot move. */
42
+ | 'plain'
43
+ /** One editor per row, every row identical — the same document, so every height is the same. */
44
+ | 'uniform'
45
+ /** The `uniform` rows with their text as a paragraph: the same feed without a document at all. */
46
+ | 'uniform-text'
47
+ /** The `uniform` rows with an editor carrying nothing but line wrapping. */
48
+ | 'uniform-bare'
49
+ /** The `uniform` rows with wrapping and the theme, but no markdown and no decoration. */
50
+ | 'uniform-themed'
51
+ /** The `uniform` rows with the markdown language, but nothing drawn from what it parses. */
52
+ | 'uniform-markdown'
53
+ /** The `uniform` rows with markdown drawn as itself: headings, lists, links. */
54
+ | 'uniform-decorated'
55
+ /** The `uniform` rows rendered by the real item, reached through `Custom` like the probes. */
56
+ | 'uniform-item'
57
+ | 'assistant'
58
+ | 'email'
59
+ | 'thread'
60
+ | 'comments'
61
+ | 'transcript';
62
+
63
+ export type ScenarioDefinition = {
64
+ messages: Message.Message[];
65
+ renderer: MessageRenderer;
66
+ /** Widgets for the tags the renderer emits; only the assistant turn has non-prose blocks. */
67
+ registry?: XmlWidgetRegistry;
68
+ Chrome: ComponentType<MessageChromeProps>;
69
+ /** Renders a `custom` item, for the scenarios that deliberately avoid an editor. */
70
+ Custom?: ComponentType<{ content: any; message: Message.Message }>;
71
+ /** Whether the feed follows its tail: true where messages arrive, false where they are read. */
72
+ stickyBottom: boolean;
73
+ /**
74
+ * Height a row is assumed to have before it is measured. A function where rows differ widely: a
75
+ * one-line prompt and a long answer share no useful average, and a row measured for the first time
76
+ * re-lays everything below it — which the reader sees as flicker when scrolling up, into rows that
77
+ * have never been measured.
78
+ */
79
+ estimateSize: number | ((message: Message.Message, index: number) => number);
80
+ /** Which messages the arrows and the navigation buttons stop on. */
81
+ isAnchor?: (message: Message.Message, index: number) => boolean;
82
+ };
83
+
84
+ export type ScenarioOptions = {
85
+ scenario: FeedScenario;
86
+ count: number;
87
+ seed?: number;
88
+ };
89
+
90
+ export const createScenario = ({ scenario, count, seed = 999 }: ScenarioOptions): ScenarioDefinition => {
91
+ random.seed(seed);
92
+ switch (scenario) {
93
+ // The control: nothing here builds anything after it mounts, so a row's first measurement is its
94
+ // last. Any movement in this feed belongs to the list, not to what it is rendering.
95
+ case 'plain':
96
+ return {
97
+ messages: createPlainMessages(count),
98
+ renderer: (message) => ({ kind: 'custom', key: 'plain', data: message.properties?.height ?? 120 }),
99
+ Chrome: PlainChrome,
100
+ Custom: PlainItem,
101
+ stickyBottom: true,
102
+ estimateSize: (message) => Number(message.properties?.height ?? 120) + PLAIN_CHROME,
103
+ };
104
+
105
+ // One rung up: a real editor per row, but every row the same single paragraph, so every row is
106
+ // the same height and the estimate can be exactly right.
107
+ case 'uniform':
108
+ return {
109
+ messages: createUniformMessages(count),
110
+ renderer: defaultRenderer,
111
+ Chrome: PlainChrome,
112
+ stickyBottom: true,
113
+ estimateSize: UNIFORM_HEIGHT,
114
+ };
115
+
116
+ // The controls for `uniform`: identical messages, identical chrome, identical estimate — only
117
+ // what the row contains changes, so the difference between any two of them is that content.
118
+ case 'uniform-text':
119
+ case 'uniform-bare':
120
+ case 'uniform-themed':
121
+ case 'uniform-markdown':
122
+ case 'uniform-decorated':
123
+ case 'uniform-item':
124
+ return {
125
+ messages: createUniformMessages(count),
126
+ renderer: (message) => ({ kind: 'custom', key: scenario, data: Message.extractText(message) }),
127
+ Chrome: PlainChrome,
128
+ Custom: UNIFORM_CONTROLS[scenario],
129
+ stickyBottom: true,
130
+ estimateSize: 84,
131
+ };
132
+
133
+ case 'assistant':
134
+ return {
135
+ messages: createAssistantMessages(count),
136
+ renderer: chatRenderer,
137
+ registry: chatRegistry,
138
+ Chrome: AssistantChrome,
139
+ stickyBottom: true,
140
+ estimateSize: estimateAssistantRow,
141
+ // A turn is a prompt and everything the model said in reply; stopping on each of the model's
142
+ // messages would make the reader step through an answer rather than through the conversation.
143
+ isAnchor: (message) => message.sender.role === 'user',
144
+ };
145
+
146
+ case 'email':
147
+ return {
148
+ messages: createEmailMessages(count),
149
+ renderer: defaultRenderer,
150
+ Chrome: EmailChrome,
151
+ // A conversation is read from its start; the newest message is not where the reader is going.
152
+ stickyBottom: false,
153
+ estimateSize: 220,
154
+ };
155
+
156
+ case 'thread':
157
+ return {
158
+ messages: createThreadMessages(count),
159
+ renderer: defaultRenderer,
160
+ Chrome: ThreadChrome,
161
+ stickyBottom: true,
162
+ estimateSize: 64,
163
+ };
164
+
165
+ case 'comments':
166
+ return {
167
+ messages: createCommentMessages(count),
168
+ renderer: defaultRenderer,
169
+ Chrome: CommentChrome,
170
+ stickyBottom: false,
171
+ estimateSize: 96,
172
+ };
173
+
174
+ case 'transcript':
175
+ return {
176
+ messages: createTranscriptMessages(count),
177
+ renderer: defaultRenderer,
178
+ Chrome: TranscriptChrome,
179
+ stickyBottom: true,
180
+ estimateSize: 48,
181
+ };
182
+ }
183
+ };
184
+
185
+ /** Chrome around every assistant row: the sender line, the padding and the separator. */
186
+ const ROW_CHROME = 46;
187
+
188
+ /** Height of a wrapped line of body text, and how many characters fit on one at the story's width. */
189
+ const LINE_HEIGHT = 24;
190
+ const LINE_CHARS = 90;
191
+
192
+ /** A collapsed panel: reasoning, a tool call, a tool result. */
193
+ const PANEL_HEIGHT = 50;
194
+
195
+ /**
196
+ * What a row will measure, from the message alone.
197
+ *
198
+ * Rough on purpose — it is an estimate, and its only job is to be close enough that measuring the row
199
+ * does not move the rows below it. Text is counted in wrapped lines, blank lines between blocks are
200
+ * counted, and every block that renders as a widget is counted at its collapsed height.
201
+ */
202
+ const estimateAssistantRow = (message: Message.Message): number => {
203
+ let height = ROW_CHROME;
204
+ for (const block of message.blocks) {
205
+ switch (block._tag) {
206
+ case 'text': {
207
+ const paragraphs = block.text.split('\n\n');
208
+ for (const paragraph of paragraphs) {
209
+ height += Math.max(1, Math.ceil(paragraph.length / LINE_CHARS)) * LINE_HEIGHT;
210
+ }
211
+ break;
212
+ }
213
+ case 'suggestion':
214
+ height += LINE_HEIGHT + 8;
215
+ break;
216
+ case 'select':
217
+ height += block.options.length * (LINE_HEIGHT + 10);
218
+ break;
219
+ default:
220
+ height += PANEL_HEIGHT;
221
+ }
222
+ }
223
+
224
+ return height;
225
+ };
226
+
227
+ //
228
+ // Fixtures
229
+ //
230
+
231
+ /** Fixed heights, cycling so the feed is uneven — but known in advance and never corrected. */
232
+ const PLAIN_HEIGHTS = [64, 120, 200, 88, 320, 56];
233
+
234
+ /**
235
+ * What the chrome adds to a plain row: the separator beneath it.
236
+ *
237
+ * The estimate has to include this. A control scenario whose estimate is the *content* height is off
238
+ * by the chrome on every single row, and the list corrects each one — which looks exactly like the
239
+ * defect the control exists to rule out.
240
+ */
241
+ const PLAIN_CHROME = 1;
242
+
243
+ const createPlainMessages = (count: number): Message.Message[] =>
244
+ Array.from({ length: count }, (_, index) =>
245
+ Message.make({
246
+ created: at(index),
247
+ sender: { role: index % 2 === 0 ? 'user' : 'assistant', name: index % 2 === 0 ? 'Alice' : 'Assistant' },
248
+ blocks: [{ _tag: 'text', text: random.lorem.sentence(index % 8) }],
249
+ properties: { height: PLAIN_HEIGHTS[index % PLAIN_HEIGHTS.length] },
250
+ }),
251
+ );
252
+
253
+ /**
254
+ * What one uniform row measures, chrome included.
255
+ *
256
+ * Exact on purpose, and checked against the DOM rather than guessed: this scenario's whole job is to
257
+ * be the case where the estimate is right, and it was declared at 84 against a real 24. A viewport
258
+ * filled from an estimate three times too large covers a third of the screen once the rows measure,
259
+ * so the feed arrived in two visible pieces — in the one story that exists to have nothing wrong
260
+ * with it.
261
+ */
262
+ const UNIFORM_HEIGHT = 24;
263
+
264
+ /** Every row the same document, so a single estimate is exactly right for all of them. */
265
+ const createUniformMessages = (count: number): Message.Message[] =>
266
+ Array.from({ length: count }, (_, index) =>
267
+ Message.make({
268
+ created: at(index),
269
+ sender: { role: index % 2 === 0 ? 'user' : 'assistant', name: index % 2 === 0 ? 'Alice' : 'Assistant' },
270
+ // Not `${index}. …`: markdown reads a leading number and a dot as an ordered list and renumbers
271
+ // it, so the row would show a number that is not its index. The index is in the chrome.
272
+ blocks: [{ _tag: 'text', text: random.lorem.sentence(index % 16 === 0 ? 100 : 1) }],
273
+ }),
274
+ );
275
+
276
+ const NAMES = ['Alice', 'Bob', 'Charlie', 'Dana'];
277
+
278
+ const at = (index: number, step = 60_000) => new Date(Date.now() - (2_000 - index) * step).toISOString();
279
+
280
+ /** A turn: the reader's prompt, then an answer carrying the block kinds a model actually emits. */
281
+ const createAssistantMessages = (count: number): Message.Message[] =>
282
+ Array.from({ length: count }, (_, index) => {
283
+ if (index % 2 === 0) {
284
+ return Message.make({
285
+ created: at(index),
286
+ sender: { role: 'user', name: 'Alice' },
287
+ blocks: [{ _tag: 'text', text: random.lorem.paragraph() }],
288
+ });
289
+ }
290
+
291
+ // Every answer is several blocks — reasoning, sometimes a tool round-trip, prose, and closing
292
+ // affordances. A single text block is the easy case and the one the engine already handled.
293
+ const blocks: ContentBlock.Any[] = [{ _tag: 'reasoning', reasoningText: random.lorem.paragraph() }];
294
+ if (index % 4 === 1) {
295
+ const toolCallId = `tool-${index}`;
296
+ blocks.push({
297
+ _tag: 'toolCall',
298
+ toolCallId,
299
+ name: 'search',
300
+ input: '{}',
301
+ providerExecuted: false,
302
+ });
303
+ blocks.push({
304
+ _tag: 'toolResult',
305
+ toolCallId,
306
+ name: 'search',
307
+ result: random.lorem.paragraphs(2),
308
+ providerExecuted: false,
309
+ });
310
+ }
311
+
312
+ blocks.push({ _tag: 'text', text: answerText(index) });
313
+ if (index % 6 === 3) {
314
+ blocks.push({ _tag: 'suggestion', text: 'Tell me more' });
315
+ blocks.push({ _tag: 'suggestion', text: 'Show the sources' });
316
+ }
317
+ if (index % 8 === 5) {
318
+ blocks.push({ _tag: 'select', options: ['Option 1', 'Option 2', 'Option 3'] });
319
+ }
320
+
321
+ return Message.make({ created: at(index), sender: { role: 'assistant', name: 'Assistant' }, blocks });
322
+ });
323
+
324
+ const answerText = (index: number): string =>
325
+ [
326
+ `**${index}.** ${random.lorem.sentence(10)}`,
327
+ '',
328
+ random.lorem.paragraph(),
329
+ '',
330
+ `- ${random.lorem.sentence(5)}`,
331
+ `- ${random.lorem.sentence(7)}`,
332
+ ].join('\n');
333
+
334
+ /** An email conversation: HTML bodies, quoted history, and a subject that never changes. */
335
+ const createEmailMessages = (count: number): Message.Message[] => {
336
+ const subject = `Re: ${random.lorem.words(4)}`;
337
+ return Array.from({ length: count }, (_, index) => {
338
+ const from = NAMES[index % NAMES.length];
339
+ return Message.make({
340
+ created: at(index, 3_600_000),
341
+ sender: { role: 'user', name: from, email: `${from.toLowerCase()}@example.com` },
342
+ blocks: [
343
+ {
344
+ _tag: 'text',
345
+ mimeType: 'text/html',
346
+ text: [
347
+ `<p>${random.lorem.paragraph()}</p>`,
348
+ `<p>${random.lorem.paragraph()}</p>`,
349
+ '<ul><li>attachment.pdf</li><li>invoice.csv</li></ul>',
350
+ `<blockquote>${random.lorem.sentence(16)}</blockquote>`,
351
+ ].join(''),
352
+ },
353
+ ],
354
+ properties: { subject },
355
+ });
356
+ });
357
+ };
358
+
359
+ /** Human chat: short turns, many of them, often several in a row from one speaker. */
360
+ const createThreadMessages = (count: number): Message.Message[] =>
361
+ Array.from({ length: count }, (_, index) => {
362
+ const name = NAMES[Math.floor(index / 3) % NAMES.length];
363
+ return Message.make({
364
+ created: at(index, 30_000),
365
+ sender: { role: 'user', name },
366
+ blocks: [{ _tag: 'text', text: random.lorem.sentence(random.number.int({ min: 4, max: 20 })) }],
367
+ });
368
+ });
369
+
370
+ /** Comments: each anchored to a range of a document, and resolvable. */
371
+ const createCommentMessages = (count: number): Message.Message[] =>
372
+ Array.from({ length: count }, (_, index) =>
373
+ Message.make({
374
+ created: at(index, 120_000),
375
+ sender: { role: 'user', name: NAMES[index % NAMES.length] },
376
+ blocks: [{ _tag: 'text', text: random.lorem.sentence(random.number.int({ min: 6, max: 24 })) }],
377
+ properties: { anchor: random.lorem.sentence(6), resolved: index % 7 === 0 },
378
+ }),
379
+ );
380
+
381
+ /** Transcription: one utterance per message, seconds apart, arriving while the reader watches. */
382
+ const createTranscriptMessages = (count: number): Message.Message[] =>
383
+ Array.from({ length: count }, (_, index) =>
384
+ Message.make({
385
+ created: at(index, 6_000),
386
+ sender: { role: 'user', name: NAMES[index % 2] },
387
+ blocks: [{ _tag: 'text', text: random.lorem.sentence(random.number.int({ min: 5, max: 18 })) }],
388
+ }),
389
+ );
390
+
391
+ /**
392
+ * A row of a known height: the content is a box, not a document.
393
+ *
394
+ * The height is the point — it is declared, never measured — so it is stated in the row rather than
395
+ * left to be inferred, and the text sits at the top like every other scenario's does.
396
+ */
397
+ const PlainItem = ({ content, message }: { content: { data?: unknown }; message: Message.Message }) => {
398
+ const height = Number(content.data ?? 120);
399
+
400
+ return (
401
+ <div className='flex flex-col gap-1 py-2 text-sm' style={{ height }}>
402
+ <div className='flex items-center gap-2 text-xs text-description'>
403
+ <span className='font-medium'>{message.sender.name}</span>
404
+ <span className='tabular-nums'>{height}px</span>
405
+ </div>
406
+ <p>{Message.extractText(message)}</p>
407
+ </div>
408
+ );
409
+ };
410
+
411
+ //
412
+ // Chrome
413
+ //
414
+
415
+ /** No hover controls and no separators: nothing here can change a row's height. */
416
+ /**
417
+ * The index is shown in the gutter because these are the control scenarios: when a row moves, the
418
+ * first question is *which* row, and reading it off the screen beats hunting for it in the DOM.
419
+ */
420
+ const PlainChrome = ({ index, children }: MessageChromeProps) => (
421
+ <Row classNames='py-0 grid grid-cols-[3rem_1fr] gap-2'>
422
+ <div className='self-start flex items-center h-[1lh] text-base leading-normal'>
423
+ <span className='text-xs text-subdued tabular-nums'>{index}</span>
424
+ </div>
425
+ <div className='min-w-0'>{children}</div>
426
+ </Row>
427
+ );
428
+
429
+ //
430
+ // Every scenario's difference that is not the renderer lives here — which is the claim being
431
+ // tested: chrome is the host's, and the engine only has to keep it out of the measurement.
432
+ //
433
+
434
+ const timeOf = (message: Message.Message, options?: Intl.DateTimeFormatOptions) =>
435
+ new Date(message.created).toLocaleTimeString([], options ?? { hour: '2-digit', minute: '2-digit' });
436
+
437
+ const Row = ({ children, classNames }: { children: React.ReactNode; classNames?: string }) => (
438
+ <div className={mx('group relative px-2 py-2', classNames)} data-testid='feed.message'>
439
+ {children}
440
+ </div>
441
+ );
442
+
443
+ /**
444
+ * Assistant: the reader's prompts and the model's answers are framed differently, because they are
445
+ * different kinds of thing — a prompt is an instruction that can be rewound to, an answer is a
446
+ * result that can be forked from.
447
+ *
448
+ * A prompt carries its rewind toolbar underneath; an answer carries its timestamp at the foot, where
449
+ * it reads as when the answer finished rather than when the turn began. Both are always in flow — a
450
+ * control that appears on hover changes the row's height, and a pointer travelling down a scrolling
451
+ * list then moves every row below it.
452
+ */
453
+ const AssistantChrome = ({ message, index, selected, children }: MessageChromeProps) => {
454
+ const prompt = message.sender.role === 'user';
455
+
456
+ return (
457
+ <Row classNames={mx(selected && 'bg-hover-surface')}>
458
+ {prompt ? (
459
+ <div className='min-w-0'>
460
+ {/* The reader's own words, framed: a prompt is an instruction the thread can be rewound
461
+ to, and it reads as one only if it is visibly not the model's prose. */}
462
+ <div className='ps-2 pe-2 pt-1 pb-1 border-s-2 border-accent-bg rounded-sm bg-input-surface'>{children}</div>
463
+ {/* Revealed on hover, but never removed from flow: chrome that appears and disappears
464
+ changes the row's height, and a pointer travelling down a scrolling list would then
465
+ move every row below it. Opacity costs nothing to measure. */}
466
+ <div className='flex items-center gap-1 pt-1 text-xs text-description opacity-0 transition-opacity group-hover:opacity-100'>
467
+ <IconButton icon='ph--arrow-counter-clockwise--regular' iconOnly label='Rewind' variant='ghost' size={3} />
468
+ <IconButton icon='ph--git-branch--regular' iconOnly label='Fork' variant='ghost' size={3} />
469
+ <span>{timeOf(message)}</span>
470
+ <span className='text-subdued'>#{index}</span>
471
+ </div>
472
+ </div>
473
+ ) : (
474
+ <div className='min-w-0'>
475
+ {children}
476
+ <div className='flex items-center gap-2 pt-1 text-xs text-description opacity-0 transition-opacity group-hover:opacity-100'>
477
+ <span className='font-medium'>{message.sender.name}</span>
478
+ <span>{timeOf(message)}</span>
479
+ <span className='text-subdued'>#{index}</span>
480
+ <span className='grow' />
481
+ <IconButton icon='ph--arrow-bend-up-left--regular' iconOnly label='Reply' variant='ghost' size={3} />
482
+ </div>
483
+ </div>
484
+ )}
485
+ </Row>
486
+ );
487
+ };
488
+
489
+ /** Email: a header of its own — sender, address, date — and a card-like body. */
490
+ const EmailChrome = ({ message, children }: MessageChromeProps) => (
491
+ <Row classNames='border-b border-separator'>
492
+ <div className='flex items-baseline gap-2'>
493
+ <span className='font-medium'>{message.sender.name}</span>
494
+ <span className='text-xs text-description'>{message.sender.email}</span>
495
+ <span className='grow' />
496
+ <span className='text-xs text-description'>{new Date(message.created).toLocaleString()}</span>
497
+ </div>
498
+ <p className='text-xs text-description'>{String(message.properties?.subject ?? '')}</p>
499
+ <div className='mt-1'>{children}</div>
500
+ </Row>
501
+ );
502
+
503
+ /** Human chat: an avatar, and consecutive turns from one speaker read as one block. */
504
+ const ThreadChrome = ({ message, children }: MessageChromeProps) => (
505
+ <Row classNames='grid grid-cols-[2rem_1fr] gap-2'>
506
+ <div className='w-6 h-6 rounded-full bg-input-surface grid place-items-center text-xs'>
507
+ {message.sender.name?.[0]}
508
+ </div>
509
+ <div className='min-w-0'>
510
+ <div className='flex items-center gap-2 text-xs text-description'>
511
+ <span className='font-medium'>{message.sender.name}</span>
512
+ <span>{timeOf(message)}</span>
513
+ </div>
514
+ {children}
515
+ </div>
516
+ </Row>
517
+ );
518
+
519
+ /** Comments: the quoted anchor above the comment, and a resolve control. */
520
+ const CommentChrome = ({ message, children }: MessageChromeProps) => (
521
+ <Row classNames={mx('border-b border-subdued-separator', message.properties?.resolved && 'opacity-50')}>
522
+ <p className='mb-1 ps-2 border-s-2 border-separator text-xs text-description line-clamp-1'>
523
+ {String(message.properties?.anchor ?? '')}
524
+ </p>
525
+ <div className='flex items-center gap-2 text-xs text-description'>
526
+ <span className='font-medium'>{message.sender.name}</span>
527
+ <span>{timeOf(message)}</span>
528
+ <span className='grow' />
529
+ <IconButton
530
+ icon={message.properties?.resolved ? 'ph--check-circle--regular' : 'ph--circle--regular'}
531
+ iconOnly
532
+ label='Resolve'
533
+ variant='ghost'
534
+ size={3}
535
+ />
536
+ </div>
537
+ {children}
538
+ </Row>
539
+ );
540
+
541
+ /** Transcription: a timestamp and speaker in the gutter, and no separators — it reads as one text. */
542
+ const TranscriptChrome = ({ message, children }: MessageChromeProps) => (
543
+ <Row classNames='grid grid-cols-[5rem_1fr] gap-2 py-0.5'>
544
+ <div className='text-xs text-description tabular-nums'>
545
+ {timeOf(message, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
546
+ </div>
547
+ <div className='min-w-0'>
548
+ <span className='me-2 text-xs font-medium text-description'>{message.sender.name}</span>
549
+ {children}
550
+ </div>
551
+ </Row>
552
+ );
@@ -0,0 +1,70 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { random } from '@dxos/random';
6
+
7
+ export type TextStreamOptions = {
8
+ /** Delay between chunks in ms. */
9
+ chunkDelay?: number;
10
+ /** Variance in timing (0-1). */
11
+ variance?: number;
12
+ /** Words per chunk. */
13
+ wordsPerChunk?: number;
14
+ };
15
+
16
+ /**
17
+ * Word-by-word stream, the shape a model emits: chunks of a few words at an uneven cadence.
18
+ *
19
+ * Whitespace is preserved as its own token so a chunk boundary never fuses two words, which would
20
+ * corrupt the markdown the item is parsing mid-stream.
21
+ */
22
+ export async function* textStream(text: string, options: TextStreamOptions = {}): AsyncGenerator<string, void> {
23
+ const { chunkDelay = 120, variance = 0.4, wordsPerChunk = 4 } = options;
24
+ // Floored rather than validated: this is driven by a storybook control, and a size of zero never
25
+ // advances the cursor — the generator would yield empty chunks for ever, which presents as a hang
26
+ // rather than a mistake.
27
+ const chunkSize = Math.max(1, Math.floor(wordsPerChunk));
28
+ const tokens = text.match(/\S+|\s+/g) ?? [];
29
+
30
+ let index = 0;
31
+ while (index < tokens.length) {
32
+ const chunk: string[] = [];
33
+ let words = 0;
34
+ while (index < tokens.length && words < chunkSize) {
35
+ const token = tokens[index++];
36
+ chunk.push(token);
37
+ if (token.trim()) {
38
+ words++;
39
+ }
40
+ }
41
+
42
+ yield chunk.join('');
43
+ await new Promise((resolve) => setTimeout(resolve, chunkDelay * (1 + (Math.random() - 0.5) * variance * 2)));
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A plausible assistant answer: prose, a list, a code block and a closing paragraph — enough
49
+ * structure that the item re-parses markdown as the text arrives, rather than growing one flat
50
+ * paragraph.
51
+ */
52
+ export const createAnswer = (): string =>
53
+ [
54
+ random.lorem.paragraph(),
55
+ '',
56
+ `## ${random.lorem.sentence(3)}`,
57
+ '',
58
+ random.lorem.paragraph(),
59
+ '',
60
+ `- **${random.lorem.words(2)}** — ${random.lorem.sentence(8)}`,
61
+ `- **${random.lorem.words(2)}** — ${random.lorem.sentence(10)}`,
62
+ `- **${random.lorem.words(2)}** — ${random.lorem.sentence(6)}`,
63
+ '',
64
+ '```ts',
65
+ 'const feed = useQuery(db, Query.from(feed));',
66
+ 'const hits = searchFeed(messages, renderer, query);',
67
+ '```',
68
+ '',
69
+ random.lorem.paragraph(),
70
+ ].join('\n');