@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,452 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
6
+
7
+ import { random } from '@dxos/random';
8
+ import { IconButton, Input, Panel, ThemedClassName, Toolbar } from '@dxos/react-ui';
9
+ import { Message } from '@dxos/types';
10
+ import { mx } from '@dxos/ui-theme';
11
+
12
+ import { Outline, type OutlineMarker } from '../components';
13
+ import { type MessageChromeProps, MessageList, useMessageList } from '../components';
14
+ import { type Decoration, DecorationsProvider, ItemSelectionProvider } from '../hooks';
15
+ import { SearchHit, defaultRenderer, searchFeed, sliceFeed, useFeedModel } from '../model';
16
+ import { FeedStats, useFeedDebug } from './debug';
17
+ import { createMessages } from './generator';
18
+ import { type FeedScenario, createScenario } from './scenarios';
19
+ import { createAnswer, textStream } from './stream';
20
+ import { streamTurn } from './turn';
21
+
22
+ /** Pause between one answer finishing and the next question arriving. */
23
+ const TURN_DELAY = 800;
24
+
25
+ /** A turn that is only a growing text tail: what every scenario but the assistant chat produces. */
26
+ async function* textTurn(options: { wordsPerChunk: number; chunkDelay: number }) {
27
+ let text = '';
28
+ for await (const chunk of textStream(createAnswer(), options)) {
29
+ text += chunk;
30
+ yield [{ _tag: 'text' as const, text, pending: true }];
31
+ }
32
+ yield [{ _tag: 'text' as const, text }];
33
+ }
34
+
35
+ const makeQuestion = () =>
36
+ Message.make({
37
+ sender: { role: 'user', name: 'Alice' },
38
+ blocks: [{ _tag: 'text', text: random.lorem.sentence(8) }],
39
+ });
40
+
41
+ const makeAnswer = () =>
42
+ Message.make({
43
+ sender: { role: 'assistant', name: 'Assistant' },
44
+ blocks: [{ _tag: 'text', text: '' }],
45
+ });
46
+
47
+ //
48
+ // Chrome
49
+ //
50
+
51
+ /**
52
+ * Per-message chrome, supplied by the host rather than the engine — the thing a single thread-wide
53
+ * document cannot do without injecting widgets into its own markdown.
54
+ */
55
+ const TestChrome = ({ message, index, selected, onSelect, children }: MessageChromeProps) => {
56
+ const role = message.sender.role ?? 'user';
57
+ const time = new Date(message.created).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
58
+
59
+ return (
60
+ <div
61
+ className={mx('group relative grid grid-cols-[2rem_1fr] gap-2 px-2 py-2', selected && 'bg-hover-surface')}
62
+ data-testid='feed.message'
63
+ >
64
+ <div className='flex flex-col items-center gap-1'>
65
+ <Input.Root>
66
+ <Input.Checkbox
67
+ checked={selected}
68
+ onCheckedChange={() => onSelect(message.id, true)}
69
+ data-testid='feed.message.select'
70
+ />
71
+ </Input.Root>
72
+ </div>
73
+
74
+ {/*
75
+ Fork / rewind / reply — chrome, not content. Positioned out of flow and toggled by opacity:
76
+ anything that changes a row's height on hover re-triggers the virtualizer's measurement, and
77
+ a pointer moving down the list during a scroll then shifts every row below it.
78
+ */}
79
+ <div className='absolute right-1 top-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
80
+ <IconButton icon='ph--git-branch--regular' iconOnly label='Fork' variant='ghost' size={3} />
81
+ <IconButton icon='ph--arrow-counter-clockwise--regular' iconOnly label='Rewind' variant='ghost' size={3} />
82
+ <IconButton icon='ph--arrow-bend-up-left--regular' iconOnly label='Reply' variant='ghost' size={3} />
83
+ </div>
84
+
85
+ <div className='min-w-0'>
86
+ <div className='flex items-center gap-2 text-xs text-description'>
87
+ <span className='font-medium'>{message.sender.name ?? role}</span>
88
+ <span>{time}</span>
89
+ <span className='text-subdued'>#{index}</span>
90
+ </div>
91
+ {children}
92
+ </div>
93
+ </div>
94
+ );
95
+ };
96
+
97
+ //
98
+ // Story
99
+ //
100
+
101
+ export type FeedStoryProps = {
102
+ /**
103
+ * Which downstream call site to approximate. Each supplies its own renderer, chrome and follow
104
+ * behaviour; everything else — the list, virtualization, selection, search — is the same engine.
105
+ * Omitted, the story runs the synthetic mixed feed the measurements were taken against.
106
+ */
107
+ scenario?: FeedScenario;
108
+ /** Messages of seeded history; 0 starts empty. */
109
+ count?: number;
110
+ /** Outline every item and every block inside it; the toolbar toggles it too. */
111
+ debug?: boolean;
112
+ /** Begin streaming turns on mount. */
113
+ streaming?: boolean;
114
+ wordsPerChunk?: number;
115
+ chunkDelay?: number;
116
+ estimateSize?: number;
117
+ /** Reserve space below the last row so any message can be brought to the top. */
118
+ scrollPastEnd?: boolean;
119
+ };
120
+
121
+ /**
122
+ * The one renderer every `MessageList` story uses; the stories differ only by args.
123
+ *
124
+ * Seeded history, a search over the model, message-set selection, and a play control that streams
125
+ * turns into the tail until stopped — one harness, so a story is a set of arguments rather than
126
+ * another component to keep in step.
127
+ */
128
+ export const FeedStory = ({
129
+ scenario,
130
+ scrollPastEnd,
131
+ count = 0,
132
+ debug: debugProp,
133
+ streaming: streamingProp = false,
134
+ wordsPerChunk = 4,
135
+ chunkDelay = 120,
136
+ estimateSize,
137
+ }: FeedStoryProps) => {
138
+ const definition = useMemo(() => (scenario ? createScenario({ scenario, count }) : undefined), [scenario, count]);
139
+ const [messages, setMessages] = useState<Message.Message[]>(() => definition?.messages ?? createMessages({ count }));
140
+ const [streaming, setStreaming] = useState(streamingProp);
141
+ const [answerId, setAnswerId] = useState<string | undefined>();
142
+ const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
143
+ const [copied, setCopied] = useState<string | null>(null);
144
+ const [query, setQuery] = useState('');
145
+
146
+ const hits = useMemo(() => searchFeed(messages, defaultRenderer, query), [messages, query]);
147
+
148
+ // Search is a decoration producer, not a list prop (SPEC §Aspects): the ranges reach the items
149
+ // through the provider below, and the list neither knows nor routes them.
150
+ const decorations = useMemo<Decoration[]>(
151
+ () => hits.map(({ messageId, offset, length }) => ({ id: messageId, range: { offset, length }, kind: 'search' })),
152
+ [hits],
153
+ );
154
+
155
+ // The model owns identity, stops and streaming; the array survives as this adapter (SPEC F-7.3).
156
+ const isAnchor = definition?.isAnchor;
157
+ const model = useFeedModel(messages, { stops: isAnchor ?? 'message' });
158
+
159
+ // Instrumentation as an aspect: the meter, the sweep, the outlines and the pass label live in
160
+ // `useFeedDebug` so this component stays about the feed.
161
+ const { debug, toggleDebug, meter, viewportRef, sweeping, onSweep } = useFeedDebug({
162
+ scenario,
163
+ count,
164
+ estimateSize,
165
+ streaming,
166
+ enabled: debugProp,
167
+ });
168
+
169
+ const handleAppend = useCallback(() => setMessages((prev) => [...prev, makeQuestion()]), []);
170
+
171
+ const handleReset = useCallback(() => {
172
+ setStreaming(false);
173
+ setAnswerId(undefined);
174
+ setMessages(definition?.messages ?? createMessages({ count }));
175
+ }, [count, definition]);
176
+
177
+ const handleCopy = useCallback(() => {
178
+ // Copy across message boundaries is reconstructed from the model: unmounted items are not in the
179
+ // DOM, so a native selection could never have included them.
180
+ if (messages.length < 3) {
181
+ return;
182
+ }
183
+
184
+ setCopied(
185
+ sliceFeed(messages, defaultRenderer, {
186
+ from: { messageId: messages[0].id, offset: 0 },
187
+ to: { messageId: messages[2].id, offset: 20 },
188
+ }),
189
+ );
190
+ }, [messages]);
191
+
192
+ // Turns keep arriving until stopped: an unbounded conversation is what the list has to survive,
193
+ // and the only way to watch the follow behave across more than one answer.
194
+ useEffect(() => {
195
+ if (!streaming) {
196
+ return;
197
+ }
198
+
199
+ let cancelled = false;
200
+ void (async () => {
201
+ while (!cancelled) {
202
+ const answer = makeAnswer();
203
+ setMessages((prev) => [...prev, makeQuestion(), answer]);
204
+ setAnswerId(answer.id);
205
+
206
+ // A chat turn arrives as blocks — status, reasoning, a tool call, then the answer — which is
207
+ // what makes reconciliation hard: the document shrinks and rewrites as well as grows. Every
208
+ // other scenario streams a plain text tail, the easy half.
209
+ const turn =
210
+ scenario === 'assistant'
211
+ ? streamTurn({ wordsPerChunk, chunkDelay })
212
+ : textTurn({ wordsPerChunk, chunkDelay });
213
+
214
+ for await (const blocks of turn) {
215
+ if (cancelled) {
216
+ return;
217
+ }
218
+ setMessages((prev) =>
219
+ prev.map((message) => (message.id === answer.id ? ({ ...message, blocks } as Message.Message) : message)),
220
+ );
221
+ }
222
+
223
+ setAnswerId(undefined);
224
+ await new Promise((resolve) => setTimeout(resolve, TURN_DELAY));
225
+ }
226
+ })();
227
+
228
+ return () => {
229
+ cancelled = true;
230
+ // A turn interrupted before its first chunk leaves an empty answer behind, which renders as a
231
+ // tall blank row. Strict mode runs this teardown on mount, so it is the common case.
232
+ setMessages((prev) => {
233
+ const last = prev.at(-1);
234
+ return last && last.sender.role === 'assistant' && !Message.extractText(last).length ? prev.slice(0, -1) : prev;
235
+ });
236
+ };
237
+ }, [streaming, wordsPerChunk, chunkDelay, scenario]);
238
+
239
+ const onSelect = useCallback((id: string, additive: boolean) => {
240
+ setSelectedIds((current) => {
241
+ const next = new Set(additive ? current : []);
242
+ if (additive && next.has(id)) {
243
+ next.delete(id);
244
+ } else {
245
+ next.add(id);
246
+ }
247
+ return next;
248
+ });
249
+ }, []);
250
+
251
+ return (
252
+ // Root is headless, so the toolbar and statusbar sit outside the scroll container and still read
253
+ // the list's state through `useMessageList`. The providers around it are the aspect pattern:
254
+ // selection and decorations reach the items by id, and the list never sees either.
255
+ <DecorationsProvider decorations={decorations}>
256
+ <ItemSelectionProvider selectedIds={selectedIds} onSelect={onSelect}>
257
+ <MessageList.Root
258
+ model={model}
259
+ renderer={definition?.renderer}
260
+ debug={debug}
261
+ Chrome={definition?.Chrome ?? TestChrome}
262
+ Custom={definition?.Custom}
263
+ registry={definition?.registry}
264
+ estimateSize={estimateSize ?? definition?.estimateSize}
265
+ stickyBottom={definition?.stickyBottom ?? true}
266
+ scrollPastEnd={scrollPastEnd}
267
+ >
268
+ <Panel.Root>
269
+ <Panel.Toolbar asChild>
270
+ <Toolbar.Root>
271
+ <IconButton
272
+ icon={streaming ? 'ph--stop--regular' : 'ph--play--regular'}
273
+ iconOnly
274
+ label={streaming ? 'Stop' : 'Start'}
275
+ data-testid='feed.stream.toggle'
276
+ onClick={() => setStreaming((value) => !value)}
277
+ />
278
+ <IconButton
279
+ icon='ph--plus--regular'
280
+ iconOnly
281
+ label='Add message'
282
+ data-testid='feed.stream.append'
283
+ onClick={handleAppend}
284
+ />
285
+ <IconButton icon='ph--trash--regular' iconOnly label='Reset' onClick={handleReset} />
286
+ <IconButton
287
+ icon={debug ? 'ph--bounding-box--fill' : 'ph--bounding-box--regular'}
288
+ iconOnly
289
+ label={debug ? 'Hide block outlines' : 'Show block outlines'}
290
+ data-testid='feed.debug.toggle'
291
+ onClick={toggleDebug}
292
+ />
293
+ <Toolbar.Separator />
294
+ <Input.Root>
295
+ <Input.TextInput
296
+ placeholder='Search…'
297
+ value={query}
298
+ onChange={(event) => setQuery(event.target.value)}
299
+ data-testid='feed.search'
300
+ />
301
+ </Input.Root>
302
+ <FindButton hits={hits} />
303
+ <IconButton icon='ph--copy--regular' iconOnly label='Copy range' onClick={handleCopy} />
304
+ <IconButton
305
+ icon={sweeping ? 'ph--stop--regular' : 'ph--arrows-down-up--regular'}
306
+ iconOnly
307
+ label={sweeping ? 'Stop sweep' : 'Sweep (measure a pass)'}
308
+ data-testid='feed.sweep'
309
+ onClick={onSweep}
310
+ />
311
+ <div className='grow' />
312
+ <NavButtons />
313
+ </Toolbar.Root>
314
+ </Panel.Toolbar>
315
+
316
+ <Panel.Content classNames='relative dx-container'>
317
+ <div className='z-10 absolute left-0 top-0 bottom-0 grid grid-rows-[1fr_4fr_1fr] justify-center'>
318
+ <FeedOutline classNames='row-start-2' messages={messages} />
319
+ </div>
320
+ <MessageList.Viewport classNames='absolute inset-0' padding ref={viewportRef} />
321
+ </Panel.Content>
322
+ </Panel.Root>
323
+ <FeedStats meter={meter} streaming={streaming} selected={selectedIds.size} hits={hits.length} />
324
+ </MessageList.Root>
325
+ </ItemSelectionProvider>
326
+ </DecorationsProvider>
327
+ );
328
+ };
329
+
330
+ /**
331
+ * The outline of a feed: one tick per prompt, in message-index space.
332
+ *
333
+ * `OutlineMarker.range` is "the document's own position space", which for a feed is the message
334
+ * index — so a marker is `[index, index + 1)` and the visible range is the mounted window. No
335
+ * pixel offsets are involved, which is what makes this survive rows whose heights are still
336
+ * estimates.
337
+ */
338
+ const FeedOutline = ({ classNames, messages }: ThemedClassName<{ messages: readonly Message.Message[] }>) => {
339
+ const { currentIndex, scrollToIndex } = useMessageList('FeedOutline');
340
+
341
+ const markers = useMemo<OutlineMarker[]>(() => {
342
+ // Every prompt is passed; the rail thins them to whatever its height affords.
343
+ return messages
344
+ .map((message, index) => ({ message, index }))
345
+ .filter(({ message }) => message.sender.role === 'user')
346
+ .map(({ message, index }) => ({
347
+ id: message.id,
348
+ title: `#${index} · ${message.sender.name ?? message.sender.role}`,
349
+ // Tags stripped: an html-kind message's text is markup, which reads as noise in a tooltip.
350
+ description: Message.extractText(message)
351
+ .replace(/<[^>]*>/g, ' ')
352
+ .split('\n')
353
+ .map((line) => line.trim())
354
+ .find(Boolean)
355
+ ?.slice(0, 160),
356
+ // A tick marks one message, not a span: which one is current is decided below, so the range
357
+ // stays honest about what the tick points at.
358
+ range: { from: index, to: index + 1 },
359
+ }));
360
+ }, [messages]);
361
+
362
+ // Exactly one tick reads as current — the last prompt at or before the cursor. Passing the
363
+ // mounted window instead would light up whichever ticks happen to fall inside it: none for most
364
+ // of a sampled rail, two when the window straddles a boundary.
365
+ const current = useMemo(() => {
366
+ const marker = [...markers].reverse().find(({ range: { from } }) => from <= currentIndex) ?? markers[0];
367
+ return marker?.range;
368
+ }, [markers, currentIndex]);
369
+
370
+ const handleSelect = useCallback(
371
+ (marker: OutlineMarker) => scrollToIndex(marker.range.from, { align: 'start', behavior: 'smooth' }),
372
+ [scrollToIndex],
373
+ );
374
+
375
+ if (!markers.length) {
376
+ return <div />;
377
+ }
378
+
379
+ return (
380
+ <Outline
381
+ classNames={['justify-self-center', classNames]}
382
+ markers={markers}
383
+ visibleRange={current}
384
+ onSelect={handleSelect}
385
+ />
386
+ );
387
+ };
388
+
389
+ /** Scroll-to-hit lives in the toolbar, outside the viewport — the reason Root is headless. */
390
+ const FindButton = ({ hits }: { hits: readonly SearchHit[] }) => {
391
+ const { scrollToIndex } = useMessageList('FindButton');
392
+ const handleFind = useCallback(() => {
393
+ const hit = hits[0];
394
+ if (hit) {
395
+ scrollToIndex(hit.index, { align: 'center', behavior: 'smooth' });
396
+ }
397
+ }, [hits, scrollToIndex]);
398
+
399
+ return <IconButton icon='ph--magnifying-glass--regular' iconOnly label='Find' onClick={handleFind} />;
400
+ };
401
+
402
+ /** Move by one message, or to either end, from the list's cursor (what the arrow keys also move). */
403
+ const NavButtons = () => {
404
+ const { currentIndex: current, count, navigation, scrollToIndex, scrollToBottom } = useMessageList('NavButtons');
405
+
406
+ // The same seam the arrow keys use (SPEC F-3.2), so a reader who switches between them does not
407
+ // change places.
408
+ const step = navigation.step;
409
+
410
+ return (
411
+ <>
412
+ <IconButton
413
+ icon='ph--arrow-line-up--regular'
414
+ iconOnly
415
+ label='First message'
416
+ variant='ghost'
417
+ disabled={current <= 0}
418
+ data-testid='feed.nav.top'
419
+ // Smooth is requested, but a jump across more than a few rows travels towards an estimated
420
+ // offset, so `scrollToIndex` takes these instantly. Same call, honest either way.
421
+ onClick={() => scrollToIndex(0, { align: 'start', behavior: 'smooth' })}
422
+ />
423
+ <IconButton
424
+ icon='ph--caret-up--regular'
425
+ iconOnly
426
+ label='Previous message'
427
+ variant='ghost'
428
+ disabled={current <= 0}
429
+ data-testid='feed.nav.back'
430
+ onClick={() => step(-1)}
431
+ />
432
+ <IconButton
433
+ icon='ph--caret-down--regular'
434
+ iconOnly
435
+ label='Next message'
436
+ variant='ghost'
437
+ disabled={current >= count - 1}
438
+ data-testid='feed.nav.forward'
439
+ onClick={() => step(1)}
440
+ />
441
+ <IconButton
442
+ icon='ph--arrow-line-down--regular'
443
+ iconOnly
444
+ label='Last message'
445
+ variant='ghost'
446
+ disabled={count === 0}
447
+ data-testid='feed.nav.bottom'
448
+ onClick={() => scrollToBottom({ behavior: 'smooth' })}
449
+ />
450
+ </>
451
+ );
452
+ };
@@ -0,0 +1,139 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { type ComponentType, useCallback, useMemo, useRef, useState } from 'react';
6
+
7
+ import { Column } from '@dxos/react-ui';
8
+ import { type Message } from '@dxos/types';
9
+
10
+ import { HtmlBlock, MarkdownBlock, type MessageChromeProps } from '../components';
11
+ import { useListModel } from '../model';
12
+ import { type MessageRenderer, defaultRenderer } from '../model';
13
+ import { Window, type WindowController, type WindowState } from '../virtualizer';
14
+
15
+ /**
16
+ * A feed of real messages placed by `Window`, and nothing else.
17
+ *
18
+ * The bridge between the placement layer and the thing it has to replace. `placement/*` proves the
19
+ * shape against boxes that cannot lie about their size; `baseline/*` proves the current engine
20
+ * against real editors. This is where those meet: the same messages, the same renderer and the same
21
+ * chrome as a feed, but placed by the new module — so the baseline invariants can be pointed at it
22
+ * and answer whether the replacement is ready, rather than the question being settled by argument.
23
+ *
24
+ * Deliberately *not* wired into `MessageList.Root`. Root owns the follow, the sticky tail, the
25
+ * anchors and the cursor as well as the virtualizer, so swapping the placement inside it is not a
26
+ * swap but a reimplementation — and doing that before this exists would leave nothing to check it
27
+ * against.
28
+ */
29
+ export type MessageWindowProps = {
30
+ messages: Message.Message[];
31
+ renderer?: MessageRenderer;
32
+ Chrome?: ComponentType<MessageChromeProps>;
33
+ Custom?: ComponentType<{ content: any; message: Message.Message }>;
34
+ /** Extent of a row before it has been measured; a function where rows differ widely. */
35
+ estimateSize?: number | ((message: Message.Message, index: number) => number);
36
+ gutter?: 'sm' | 'md' | 'lg';
37
+ /** Keep the last message against the bottom as content arrives — a chat's standing intent. */
38
+ sticky?: boolean;
39
+ /**
40
+ * Empty extent after the last row, so it can be brought to the top of the viewport.
41
+ *
42
+ * A number, computed by the host from its own container. Not a `scrollPastEnd` mode: with the
43
+ * window placed rather than the rows, the reserve is a term in the sizer and nothing else in the
44
+ * engine knows about it (§7) — which is what the old engine could not say, since its reserve was
45
+ * a DOM spacer outside the virtualizer's coordinate space.
46
+ */
47
+ reserve?: number;
48
+ onChange?: (state: WindowState) => void;
49
+ controllerRef?: React.Ref<WindowController>;
50
+ };
51
+
52
+ const DefaultChrome = ({ children }: MessageChromeProps) => <>{children}</>;
53
+
54
+ export const MessageWindow = ({
55
+ messages,
56
+ renderer = defaultRenderer,
57
+ Chrome = DefaultChrome,
58
+ Custom,
59
+ estimateSize = 120,
60
+ gutter,
61
+ sticky,
62
+ reserve,
63
+ onChange,
64
+ controllerRef,
65
+ }: MessageWindowProps) => {
66
+ const [selectedIds, setSelectedIds] = useState(new Set<string>());
67
+ const onSelect = useCallback((id: string) => setSelectedIds((current) => new Set(current).add(id)), []);
68
+
69
+ const model = useListModel(messages, (message: Message.Message) => message.id);
70
+ const extents = useMemo(
71
+ () => ({
72
+ of: (index: number) => {
73
+ const message = messages[index];
74
+ if (!message) {
75
+ return typeof estimateSize === 'number' ? estimateSize : 120;
76
+ }
77
+
78
+ return typeof estimateSize === 'function' ? estimateSize(message, index) : estimateSize;
79
+ },
80
+ }),
81
+ [messages, estimateSize],
82
+ );
83
+
84
+ return (
85
+ <Window
86
+ classNames='h-full'
87
+ model={model}
88
+ extents={extents}
89
+ sticky={sticky}
90
+ reserve={reserve}
91
+ onChange={onChange}
92
+ controllerRef={controllerRef}
93
+ >
94
+ {(index) => {
95
+ const message = messages[index];
96
+ if (!message) {
97
+ return null;
98
+ }
99
+
100
+ return (
101
+ <Column.Root gutter={gutter}>
102
+ <Column.Center>
103
+ <Chrome message={message} index={index} selected={selectedIds.has(message.id)} onSelect={onSelect}>
104
+ <Item content={renderer(message)} message={message} Custom={Custom} />
105
+ </Chrome>
106
+ </Column.Center>
107
+ </Column.Root>
108
+ );
109
+ }}
110
+ </Window>
111
+ );
112
+ };
113
+
114
+ /**
115
+ * The item's content, resolved without the list.
116
+ *
117
+ * `MessageList.Item` reads the renderer, the registry, the hits and the widget census from
118
+ * `MessageList.Root`'s context, so it cannot be rendered outside the very thing this exists to
119
+ * replace. Repeated here rather than refactored: making the item take its dependencies as props is
120
+ * the right change and belongs in the retrofit, not in a harness written to measure it.
121
+ */
122
+ const Item = ({
123
+ content,
124
+ message,
125
+ Custom,
126
+ }: {
127
+ content: ReturnType<MessageRenderer>;
128
+ message: Message.Message;
129
+ Custom?: ComponentType<{ content: any; message: Message.Message }>;
130
+ }) => (
131
+ <>
132
+ {content.kind === 'markdown' && <MarkdownBlock text={content.text} />}
133
+ {content.kind === 'html' && <HtmlBlock html={content.html} />}
134
+ {content.kind === 'custom' && Custom && <Custom content={content} message={message} />}
135
+ </>
136
+ );
137
+
138
+ /** Kept for the stories that drive it; the ref is the same handle `placement/*` uses. */
139
+ export const useMessageWindow = () => useRef<WindowController>(null);
@@ -0,0 +1,96 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { EditorState, type Extension } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+ import React, { useLayoutEffect, useRef } from 'react';
8
+
9
+ import { type Message } from '@dxos/types';
10
+ import {
11
+ createBasicExtensions,
12
+ createMarkdownExtensions,
13
+ createThemeExtensions,
14
+ decorateMarkdown,
15
+ } from '@dxos/ui-editor';
16
+
17
+ import { MarkdownBlock } from '../components';
18
+
19
+ /**
20
+ * Controls for attributing what a row costs to mount.
21
+ *
22
+ * `baseline/mount` measures a whole feed with its fixtures already built and finds ~2ms for a row
23
+ * with no editor against ~15ms for a row with one — while the same editor constructed into an
24
+ * offscreen container costs 0.4ms (`baseline/construction`). These render the *same* rows through
25
+ * the same chrome, differing only in what is inside them, so the gap can be attributed rather than
26
+ * argued about.
27
+ */
28
+ export type ControlContent = { data?: unknown };
29
+
30
+ /** The floor: the row's text, and no editor at all. */
31
+ export const TextItem = ({ content }: { content: ControlContent; message: Message.Message }) => (
32
+ <p className='py-1 text-sm'>{String(content.data ?? '')}</p>
33
+ );
34
+
35
+ /** An editor with nothing but wrapping: no theme, no keymap, no markdown, no decoration. */
36
+ const BARE: Extension[] = [EditorView.editable.of(false), EditorState.readOnly.of(true), EditorView.lineWrapping];
37
+
38
+ /** Wrapping plus the theme — the extension the isolated profile shows to be nearly free. */
39
+ const THEMED: Extension[] = [
40
+ createBasicExtensions({ readOnly: true, editable: false, lineWrapping: true }),
41
+ createThemeExtensions({ themeMode: 'light' }),
42
+ ];
43
+
44
+ /** The theme plus the markdown language: parsing, but nothing rendered from what it parsed. */
45
+ const MARKDOWN: Extension[] = [...THEMED, createMarkdownExtensions()];
46
+
47
+ /** Everything the item has bar its highlight layer — headings, lists and links drawn as themselves. */
48
+ const DECORATED: Extension[] = [...MARKDOWN, decorateMarkdown()];
49
+
50
+ export const BareEditorItem = ({ content }: { content: ControlContent; message: Message.Message }) => (
51
+ <ProbeEditor text={String(content.data ?? '')} extensions={BARE} />
52
+ );
53
+
54
+ export const ThemedEditorItem = ({ content }: { content: ControlContent; message: Message.Message }) => (
55
+ <ProbeEditor text={String(content.data ?? '')} extensions={THEMED} />
56
+ );
57
+
58
+ export const MarkdownEditorItem = ({ content }: { content: ControlContent; message: Message.Message }) => (
59
+ <ProbeEditor text={String(content.data ?? '')} extensions={MARKDOWN} />
60
+ );
61
+
62
+ export const DecoratedEditorItem = ({ content }: { content: ControlContent; message: Message.Message }) => (
63
+ <ProbeEditor text={String(content.data ?? '')} extensions={DECORATED} />
64
+ );
65
+
66
+ /**
67
+ * The real item, reached the same way the probes are.
68
+ *
69
+ * The rung that separates *what the editor is configured with* from *what the component does around
70
+ * it*: everything outside this component is identical to `uniform-decorated`.
71
+ */
72
+ export const MarkdownProbeItem = ({ content }: { content: ControlContent; message: Message.Message }) => (
73
+ <MarkdownBlock text={String(content.data ?? '')} />
74
+ );
75
+
76
+ /**
77
+ * A deliberate copy of `MarkdownBlock`'s construction, reduced to the part being measured.
78
+ *
79
+ * Copied rather than shared: the point of a control is that it does not change when the thing it is
80
+ * a control for does.
81
+ */
82
+ const ProbeEditor = ({ text, extensions }: { text: string; extensions: Extension[] }) => {
83
+ const rootRef = useRef<HTMLDivElement>(null);
84
+
85
+ useLayoutEffect(() => {
86
+ const parent = rootRef.current;
87
+ if (!parent) {
88
+ return;
89
+ }
90
+
91
+ const view = new EditorView({ parent, state: EditorState.create({ doc: text, extensions }) });
92
+ return () => view.destroy();
93
+ }, [text, extensions]);
94
+
95
+ return <div ref={rootRef} />;
96
+ };