@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,182 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback, useMemo, useState } from 'react';
6
+
7
+ import { IconButton, type ThemedClassName } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/ui-theme';
9
+
10
+ import { useMessageList } from '../../components';
11
+ import { type FrameMeter, type Stat, Stats, warnAbove } from '../../debug';
12
+
13
+ /** What the feed currently holds, as opposed to how well it is holding it. */
14
+ const FEED_STATS: Stat[] = [
15
+ {
16
+ id: 'range',
17
+ label: 'range',
18
+ },
19
+ {
20
+ id: 'index',
21
+ label: 'index',
22
+ },
23
+ {
24
+ id: 'rows',
25
+ label: 'rows',
26
+ },
27
+ {
28
+ id: 'widgets',
29
+ label: 'widgets',
30
+ },
31
+ {
32
+ id: 'selected',
33
+ label: 'selected',
34
+ },
35
+ {
36
+ id: 'hits',
37
+ label: 'hits',
38
+ },
39
+ ];
40
+
41
+ /**
42
+ * The scroll-quality verdict: live rate, the rate at the pass's slowest fifth, the worst frame, and
43
+ * how many frames stuttered. Only meaningful at a real keyboard — an agent's browser throttles the
44
+ * frames this counts.
45
+ *
46
+ * `jumps` are rows moving against the scroll, sampled per frame; `shifts` are rows whose offset
47
+ * changed after they were laid out; `breaks` are shifts that broke the order. All three should be
48
+ * zero on a still feed, so each is coloured the moment it is not.
49
+ */
50
+ const FRAME_STATS: Stat[] = [
51
+ {
52
+ id: 'fps',
53
+ label: 'render',
54
+ unit: 'fps',
55
+ classNames: (value) => Number(value) > 0 && Number(value) < 50 && 'text-warning-text',
56
+ },
57
+ {
58
+ id: 'p95',
59
+ label: 'p95',
60
+ unit: 'ms',
61
+ },
62
+ {
63
+ id: 'worst',
64
+ label: 'worst',
65
+ unit: 'ms',
66
+ },
67
+ {
68
+ id: 'hitches',
69
+ label: 'hitches',
70
+ classNames: warnAbove,
71
+ },
72
+ {
73
+ id: 'jumps',
74
+ label: 'jumps',
75
+ classNames: warnAbove,
76
+ },
77
+ {
78
+ id: 'shifts',
79
+ label: 'shifts',
80
+ classNames: warnAbove,
81
+ },
82
+ {
83
+ id: 'breaks',
84
+ label: 'breaks',
85
+ classNames: warnAbove,
86
+ },
87
+ ];
88
+
89
+ export type FeedStatsProps = ThemedClassName<{
90
+ meter: FrameMeter;
91
+ streaming?: boolean;
92
+ selected?: number;
93
+ hits?: number;
94
+ }>;
95
+
96
+ /**
97
+ * Everything measured about a feed, floating over it.
98
+ *
99
+ * It sits above the viewport rather than in a statusbar because a statusbar is part of the layout
100
+ * being measured: a block of readouts that grows or wraps changes the height of the scroll
101
+ * container, and every number in it would then describe a viewport the readouts themselves resized.
102
+ */
103
+ export const FeedStats = ({ classNames, meter, streaming, selected = 0, hits = 0 }: FeedStatsProps) => {
104
+ const { range, currentIndex, count, mountedRows, mountedWidgets, jumps, shifts, breaks, resetShifts } =
105
+ useMessageList('FeedStats');
106
+
107
+ // A pass is the interval between two presses, so the control is start/stop rather than a reset.
108
+ const [recording, setRecording] = useState(false);
109
+ const onRecord = useCallback(() => {
110
+ meter.record();
111
+ setRecording((value) => !value);
112
+ }, [meter]);
113
+
114
+ const values = useMemo(
115
+ () => ({
116
+ range: range ? `${range.startIndex} / ${range.endIndex}` : '—',
117
+ index: `${currentIndex} / ${count}`,
118
+ rows: mountedRows,
119
+ widgets: mountedWidgets,
120
+ selected,
121
+ hits,
122
+ fps: meter.fps,
123
+ p95: meter.p95,
124
+ worst: meter.worst,
125
+ hitches: meter.hitches,
126
+ jumps: jumps.count,
127
+ shifts,
128
+ breaks,
129
+ }),
130
+ [range, currentIndex, count, mountedRows, mountedWidgets, selected, hits, meter, jumps.count, shifts, breaks],
131
+ );
132
+
133
+ const label = `${meter.label} — p50 ${meter.p50} · ${meter.frames} frames · ${(meter.duration / 1000).toFixed(1)}s`;
134
+
135
+ return (
136
+ <div
137
+ // Fixed width, not fitted: the widest line is the pass summary, whose length changes with the
138
+ // elapsed time, so a panel sized to its content would resize once a second while being read.
139
+ className={mx(
140
+ 'z-10 absolute bottom-3 right-3 w-[12rem] grid p-2 rounded-sm border border-separator bg-base-surface text-xs text-description',
141
+ classNames,
142
+ )}
143
+ data-testid='feed.stats'
144
+ >
145
+ <div className='flex items-center min-w-0'>
146
+ <span className='grow truncate' data-testid='feed.stream.state'>
147
+ {streaming ? 'streaming…' : 'idle'}
148
+ </span>
149
+ <IconButton
150
+ icon={recording ? 'ph--stop--regular' : 'ph--record--regular'}
151
+ iconOnly
152
+ label={recording ? 'End the pass' : 'Start a pass'}
153
+ variant='ghost'
154
+ size={3}
155
+ data-testid='feed.debug.record'
156
+ onClick={onRecord}
157
+ />
158
+ <IconButton
159
+ icon='ph--arrow-counter-clockwise--regular'
160
+ iconOnly
161
+ label='Reset counters'
162
+ variant='ghost'
163
+ size={3}
164
+ data-testid='feed.debug.reset'
165
+ onClick={resetShifts}
166
+ />
167
+ </div>
168
+
169
+ <Stats stats={FEED_STATS} values={values} />
170
+
171
+ {/* The pass's extent rides in the tooltip: it is context for the readings rather than one of
172
+ them, and its width changes with the elapsed time. */}
173
+ <Stats
174
+ stats={FRAME_STATS}
175
+ values={values}
176
+ classNames='border-t border-subdued-separator pt-1'
177
+ title={label}
178
+ data-testid='feed.frames'
179
+ />
180
+ </div>
181
+ );
182
+ };
@@ -0,0 +1,16 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Instrumentation, kept apart from the harness it measures.
7
+ //
8
+ // A story is about the feed; these are about how well the feed behaves. Separated because
9
+ // instrumentation accumulates — a meter, a sweep, a jump detector, the controls that reset them —
10
+ // and threaded through the story it slowly becomes the story.
11
+ //
12
+
13
+ export * from './FeedStats';
14
+
15
+ export * from './sweep';
16
+ export * from './use-feed-debug';
@@ -0,0 +1,85 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Travel speed (px/s) of a measured pass: about what a hard trackpad fling reaches on a 120Hz
7
+ * display, and the speed at which a reader still expects to see content rather than a blur.
8
+ *
9
+ * Not "the whole document in N seconds", which was the first attempt: 2,000 rows in six seconds is
10
+ * 40,000 px/s — some 400 rows a second, each one a CodeMirror view to mount and measure — and it
11
+ * collapsed the frame rate to 3fps on hardware that idles at 120. That measured the sweep, not the
12
+ * list.
13
+ */
14
+ const VELOCITY = 3_000;
15
+
16
+ /** Time spent travelling in each direction (ms), so a pass covers the same distance in every story. */
17
+ const LEG_DURATION = 5_000;
18
+
19
+ export type SweepOptions = {
20
+ /** Travel speed in px/s. @default 3000 */
21
+ velocity?: number;
22
+ /** Time for one leg (ms). @default 5000 */
23
+ duration?: number;
24
+ /** Called once the return leg lands, or when cancelled. */
25
+ onDone?: () => void;
26
+ };
27
+
28
+ /**
29
+ * Scrolls an element down and back at a fixed speed.
30
+ *
31
+ * A measured pass has to be one repeatable gesture or the numbers cannot be compared between
32
+ * stories, and a 2,000-row feed is far too long to fling by hand. Fixed speed for a fixed time —
33
+ * rather than a fixed fraction of the document — so the two stories being compared travel the same
34
+ * distance even though they disagree about how tall the document is until their rows have measured.
35
+ */
36
+ export const sweepScroll = (
37
+ element: HTMLElement,
38
+ { velocity = VELOCITY, duration = LEG_DURATION, onDone }: SweepOptions = {},
39
+ ) => {
40
+ let frame = 0;
41
+ let cancelled = false;
42
+ let start = performance.now();
43
+ let last = start;
44
+ let leg: 'down' | 'up' = 'down';
45
+
46
+ const finish = () => {
47
+ cancelAnimationFrame(frame);
48
+ onDone?.();
49
+ };
50
+
51
+ const tick = (now: number) => {
52
+ if (cancelled) {
53
+ return;
54
+ }
55
+
56
+ // Stepped by elapsed time rather than by frame, so a dropped frame costs smoothness but not
57
+ // distance — two passes over the same feed cover the same ground however each one rendered.
58
+ const step = (velocity * (now - last)) / 1000;
59
+ last = now;
60
+ const limit = element.scrollHeight - element.clientHeight;
61
+ element.scrollTop = Math.max(0, Math.min(element.scrollTop + (leg === 'down' ? step : -step), limit));
62
+
63
+ if (now - start < duration) {
64
+ frame = requestAnimationFrame(tick);
65
+ return;
66
+ }
67
+
68
+ if (leg === 'down') {
69
+ leg = 'up';
70
+ start = now;
71
+ last = now;
72
+ frame = requestAnimationFrame(tick);
73
+ return;
74
+ }
75
+
76
+ finish();
77
+ };
78
+
79
+ frame = requestAnimationFrame(tick);
80
+
81
+ return () => {
82
+ cancelled = true;
83
+ finish();
84
+ };
85
+ };
@@ -0,0 +1,91 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
+
7
+ import { type FrameMeter, useFrameMeter } from '../../debug';
8
+ import { sweepScroll } from './sweep';
9
+
10
+ export type FeedDebugOptions = {
11
+ /** What the pass ran under; the recorded line carries these rather than a story name. */
12
+ scenario?: string;
13
+ count: number;
14
+ estimateSize?: number;
15
+ streaming: boolean;
16
+ /** Whether the outlines start on. Off: they are for a question, not for every reading. */
17
+ enabled?: boolean;
18
+ };
19
+
20
+ export type FeedDebug = {
21
+ /** Whether the item and block outlines are drawn. */
22
+ debug: boolean;
23
+ toggleDebug: () => void;
24
+ /** Frame statistics and the control that records a pass. */
25
+ meter: FrameMeter;
26
+ /** Attach to the viewport: the sweep needs the element it is going to scroll. */
27
+ viewportRef: RefObject<HTMLDivElement | null>;
28
+ sweeping: boolean;
29
+ /** Start a measured pass, or cancel the one running. */
30
+ onSweep: () => void;
31
+ };
32
+
33
+ /**
34
+ * Everything the harness measures itself with, in one place.
35
+ *
36
+ * Kept apart from the story deliberately: instrumentation accumulates — a meter, a sweep, a toggle,
37
+ * a label, the refs and teardown each needs — and threaded through a component it slowly becomes
38
+ * the component. As an aspect the story stays about the feed, and what is being measured can be
39
+ * read, changed or dropped in one file.
40
+ */
41
+ export const useFeedDebug = ({
42
+ scenario,
43
+ count,
44
+ estimateSize,
45
+ streaming,
46
+ enabled = false,
47
+ }: FeedDebugOptions): FeedDebug => {
48
+ const [debug, setDebug] = useState(enabled);
49
+ const toggleDebug = useCallback(() => setDebug((value) => !value), []);
50
+
51
+ const label = useMemo(
52
+ () =>
53
+ [scenario ?? 'mixed', `${count} msgs`, estimateSize ? `est ${estimateSize}` : undefined, streaming && 'streaming']
54
+ .filter(Boolean)
55
+ .join(' · '),
56
+ [scenario, count, estimateSize, streaming],
57
+ );
58
+
59
+ const meter = useFrameMeter({ label });
60
+ const viewportRef = useRef<HTMLDivElement>(null);
61
+ const [sweeping, setSweeping] = useState(false);
62
+ const cancelSweep = useRef<(() => void) | null>(null);
63
+
64
+ // A measured pass has to be one repeatable gesture. Flinging 2,000 rows by hand is neither, so the
65
+ // sweep runs the traversal and records what it cost: click, wait, read the clipboard.
66
+ const onSweep = useCallback(() => {
67
+ if (sweeping) {
68
+ cancelSweep.current?.();
69
+ return;
70
+ }
71
+
72
+ const viewport = viewportRef.current;
73
+ if (!viewport) {
74
+ return;
75
+ }
76
+
77
+ meter.record();
78
+ setSweeping(true);
79
+ cancelSweep.current = sweepScroll(viewport, {
80
+ onDone: () => {
81
+ cancelSweep.current = null;
82
+ setSweeping(false);
83
+ meter.record();
84
+ },
85
+ });
86
+ }, [sweeping, meter]);
87
+
88
+ useEffect(() => () => cancelSweep.current?.(), []);
89
+
90
+ return { debug, toggleDebug, meter, viewportRef, sweeping, onSweep };
91
+ };
@@ -0,0 +1,90 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { random } from '@dxos/random';
6
+ import { Message } from '@dxos/types';
7
+
8
+ export type MessageKind = 'markdown' | 'html' | 'code' | 'short';
9
+
10
+ export type GeneratorOptions = {
11
+ count: number;
12
+ /** Relative weights per kind; a mixed feed is the realistic case (email threads, AI chats). */
13
+ kinds?: MessageKind[];
14
+ seed?: number;
15
+ };
16
+
17
+ const SENDERS = ['user', 'assistant'] as const;
18
+
19
+ /**
20
+ * Synthetic feed for measuring the engine.
21
+ *
22
+ * Heights are deliberately uneven — a uniform-height feed would hide exactly the measurement
23
+ * problem the spike exists to answer.
24
+ */
25
+ export const createMessages = ({
26
+ count,
27
+ kinds = ['markdown', 'markdown', 'short', 'code', 'html'],
28
+ seed = 999,
29
+ }: GeneratorOptions): Message.Message[] => {
30
+ random.seed(seed);
31
+ const start = Date.now() - count * 60_000;
32
+
33
+ return Array.from({ length: count }, (_, index) => {
34
+ const kind = kinds[index % kinds.length];
35
+ const role = SENDERS[index % SENDERS.length];
36
+ return Message.make({
37
+ created: new Date(start + index * 60_000).toISOString(),
38
+ sender: { role, name: role === 'user' ? 'Alice' : 'Assistant' },
39
+ blocks: [blockFor(kind, index)],
40
+ properties: { subject: `Message ${index}`, kind },
41
+ });
42
+ });
43
+ };
44
+
45
+ const blockFor = (kind: MessageKind, index: number) => {
46
+ switch (kind) {
47
+ case 'short':
48
+ return { _tag: 'text' as const, text: `**${index}.** ${random.lorem.sentence(6)}` };
49
+
50
+ case 'code':
51
+ return {
52
+ _tag: 'text' as const,
53
+ text: [
54
+ `**${index}.** ${random.lorem.sentence(8)}`,
55
+ '',
56
+ '```ts',
57
+ 'const feed = useQuery(db, Query.from(feed));',
58
+ 'const hits = searchFeed(messages, renderer, query);',
59
+ '```',
60
+ '',
61
+ random.lorem.sentence(12),
62
+ ].join('\n'),
63
+ };
64
+
65
+ case 'html':
66
+ return {
67
+ _tag: 'text' as const,
68
+ mimeType: 'text/html',
69
+ text: [
70
+ `<p><strong>${index}.</strong> ${random.lorem.sentence(10)}</p>`,
71
+ `<blockquote>${random.lorem.sentence(14)}</blockquote>`,
72
+ '<ul><li>attachment.pdf</li><li>invoice.csv</li></ul>',
73
+ ].join(''),
74
+ };
75
+
76
+ case 'markdown':
77
+ default:
78
+ return {
79
+ _tag: 'text' as const,
80
+ text: [
81
+ `**${index}.** ${random.lorem.sentence(10)}`,
82
+ '',
83
+ random.lorem.paragraph(),
84
+ '',
85
+ `- ${random.lorem.sentence(5)}`,
86
+ `- ${random.lorem.sentence(7)}`,
87
+ ].join('\n'),
88
+ };
89
+ }
90
+ };
@@ -0,0 +1,13 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './FeedStory';
6
+ export * from './MessageWindow';
7
+ export * from './controls';
8
+ export * from './debug';
9
+ export * from './generator';
10
+ export * from './scenarios';
11
+ export * from './stream';
12
+ export * from './turn';
13
+ export * from './widgets';