@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,1923 @@
1
+ import { A as ItemSelectionProvider, C as useWidgetState, I as DecorationsProvider, _ as sliceFeed, c as useFeedModel, g as searchFeed, i as useMessageList, l as useListModel, m as defaultRenderer, p as chatRenderer, r as MessageList, s as Window, t as Outline, v as MarkdownBlock, y as HtmlBlock } from "./chunk-src.mjs";
2
+ import { Fragment, createContext, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
4
+ import { EditorState } from "@codemirror/state";
5
+ import { EditorView } from "@codemirror/view";
6
+ import { Column, Icon, IconBlock, IconButton, Input, Panel, Toolbar } from "@dxos/react-ui";
7
+ import { createBasicExtensions, createMarkdownExtensions, createThemeExtensions, decorateMarkdown, getXmlTextChild } from "@dxos/ui-editor";
8
+ import { mx } from "@dxos/ui-theme";
9
+ import { random } from "@dxos/random";
10
+ import { Message } from "@dxos/types";
11
+ import { TogglePanel } from "@dxos/react-ui-components";
12
+ //#region src/debug/Stats.tsx
13
+ /**
14
+ * A block of readouts, in fixed tracks.
15
+ *
16
+ * Every number here changes while the feed scrolls, so the value and unit columns are given widths
17
+ * rather than sized to their contents: a column that fits itself to `4` and then to `1054` moves
18
+ * every digit beside it, and an instrument that moves cannot be read at a glance.
19
+ */
20
+ var Stats = ({ stats, values, columns = 1, classNames, title, ...props }) => /* @__PURE__ */ jsx("div", {
21
+ ...props,
22
+ title,
23
+ className: mx("grid gap-x-1 tabular-nums whitespace-nowrap", classNames),
24
+ style: { gridTemplateColumns: `repeat(${columns}, 1fr 5rem 1.5rem)` },
25
+ children: stats.map(({ id, label, unit, classNames }) => /* @__PURE__ */ jsxs(Fragment, { children: [
26
+ /* @__PURE__ */ jsx("span", {
27
+ className: "text-subdued",
28
+ children: label
29
+ }),
30
+ /* @__PURE__ */ jsx("span", {
31
+ className: mx("text-right", classNames?.(values[id])),
32
+ "data-testid": `feed.${id}`,
33
+ children: values[id]
34
+ }),
35
+ /* @__PURE__ */ jsx("span", {
36
+ className: "text-subdued",
37
+ children: unit ?? ""
38
+ })
39
+ ] }, id))
40
+ });
41
+ /** Anything above zero is a defect, not a reading. */
42
+ var warnAbove = (value) => Number(value) > 0 && "text-warning-text";
43
+ createContext(void 0);
44
+ //#endregion
45
+ //#region src/debug/frame-meter.ts
46
+ /** How often the live readout is published. Per-frame state would itself be the thing dropping frames. */
47
+ var REPORT_INTERVAL = 250;
48
+ /**
49
+ * A frame longer than this counts as a hitch: two intervals of a 60Hz display, the point at which a
50
+ * pause stops reading as motion and starts reading as a stutter. Deliberately absolute rather than
51
+ * derived from the display's rate — the question is whether a reader notices, not whether the
52
+ * hardware's budget was met.
53
+ */
54
+ var HITCH_MS = 32;
55
+ /** Frame durations are bucketed by the millisecond; anything slower than this lands in the last bucket. */
56
+ var MAX_BUCKET = 512;
57
+ /** Hitches traced per pass. A pass that produces more than this has already failed. */
58
+ var MAX_TRACE = 100;
59
+ var EMPTY = {
60
+ fps: 0,
61
+ p50: 0,
62
+ p95: 0,
63
+ worst: 0,
64
+ hitches: 0,
65
+ frames: 0,
66
+ duration: 0
67
+ };
68
+ /**
69
+ * Rate (fps) at the frame standing at `quantile` of a pass, counting from the fast end — so 0.95 is
70
+ * the slow tail, the fifth of the pass a reader would call the stutter.
71
+ *
72
+ * `buckets` counts frames by their duration in whole milliseconds. Exported for its test: this is
73
+ * the number the engine's verdict rests on, and an off-by-one in the walk would flatter it.
74
+ */
75
+ var rateAtQuantile = (buckets, frames, quantile) => {
76
+ if (!frames) return 0;
77
+ const target = frames * quantile;
78
+ let seen = 0;
79
+ for (let ms = 0; ms < buckets.length; ++ms) {
80
+ seen += buckets[ms];
81
+ if (seen >= target) return ms > 0 ? Math.round(1e3 / ms) : 0;
82
+ }
83
+ return 0;
84
+ };
85
+ /**
86
+ * Samples animation-frame intervals and records a pass on demand.
87
+ *
88
+ * This exists because scroll smoothness is the deciding criterion for the feed engine and no
89
+ * automated harness can report it: an agent's browser throttles `requestAnimationFrame`, so any
90
+ * number collected there describes the harness. Averages hide the defect — a scroll that holds
91
+ * 60fps and stalls once for 200ms reads as smooth in a mean and as broken to a reader — so the
92
+ * percentiles, the worst frame and the hitch count are reported together, and each hitch is traced
93
+ * to the console as it happens so a bad number can be turned into a cause.
94
+ *
95
+ * Durations are bucketed by the millisecond rather than kept, so a pass costs the same however long
96
+ * it runs; percentiles come out of the histogram.
97
+ */
98
+ var useFrameMeter = ({ label } = {}) => {
99
+ const [stats, setStats] = useState(EMPTY);
100
+ const buckets = useRef(new Uint32Array(MAX_BUCKET));
101
+ const worst = useRef(0);
102
+ const hitches = useRef(0);
103
+ const frames = useRef(0);
104
+ const started = useRef(performance.now());
105
+ const labelRef = useRef(label);
106
+ labelRef.current = label;
107
+ const clear = useCallback(() => {
108
+ buckets.current.fill(0);
109
+ worst.current = 0;
110
+ hitches.current = 0;
111
+ frames.current = 0;
112
+ started.current = performance.now();
113
+ setStats(EMPTY);
114
+ }, []);
115
+ const rateAt = useCallback((quantile) => rateAtQuantile(buckets.current, frames.current, quantile), []);
116
+ /**
117
+ * Ends the pass: returns its one-line summary, copies it to the clipboard, logs it, and starts a
118
+ * fresh pass. One control, because a pass is exactly the interval between two of these — start a
119
+ * clean one by clicking, run the gesture, click again to record it.
120
+ */
121
+ const record = useCallback(() => {
122
+ const seconds = (performance.now() - started.current) / 1e3;
123
+ const summary = [
124
+ labelRef.current ?? "pass",
125
+ `p50 ${rateAt(.5)}`,
126
+ `p95 ${rateAt(.95)}`,
127
+ `worst ${Math.round(worst.current)}ms`,
128
+ `${hitches.current} hitches`,
129
+ `${frames.current} frames`,
130
+ `${seconds.toFixed(1)}s`
131
+ ].join(" · ");
132
+ console.log("[feed] pass", summary);
133
+ navigator.clipboard?.writeText(summary).catch(() => {});
134
+ clear();
135
+ return summary;
136
+ }, [rateAt, clear]);
137
+ useEffect(() => {
138
+ let handle = 0;
139
+ let last = performance.now();
140
+ let windowStart = last;
141
+ let windowFrames = 0;
142
+ let traced = 0;
143
+ const tick = (now) => {
144
+ const delta = now - last;
145
+ last = now;
146
+ frames.current++;
147
+ windowFrames++;
148
+ buckets.current[Math.min(Math.round(delta), 511)]++;
149
+ if (delta > worst.current) worst.current = delta;
150
+ if (delta > HITCH_MS) {
151
+ hitches.current++;
152
+ if (traced++ < MAX_TRACE) console.debug("[feed] hitch", {
153
+ ms: Math.round(delta),
154
+ at: Math.round(now - started.current),
155
+ label: labelRef.current
156
+ });
157
+ }
158
+ const elapsed = now - windowStart;
159
+ if (elapsed >= REPORT_INTERVAL) {
160
+ setStats({
161
+ fps: Math.round(windowFrames * 1e3 / elapsed),
162
+ p50: rateAt(.5),
163
+ p95: rateAt(.95),
164
+ worst: Math.round(worst.current),
165
+ hitches: hitches.current,
166
+ frames: frames.current,
167
+ duration: Math.round(now - started.current)
168
+ });
169
+ windowStart = now;
170
+ windowFrames = 0;
171
+ }
172
+ handle = requestAnimationFrame(tick);
173
+ };
174
+ handle = requestAnimationFrame(tick);
175
+ return () => cancelAnimationFrame(handle);
176
+ }, [rateAt]);
177
+ return {
178
+ ...stats,
179
+ label,
180
+ record
181
+ };
182
+ };
183
+ //#endregion
184
+ //#region src/testing/debug/FeedStats.tsx
185
+ /** What the feed currently holds, as opposed to how well it is holding it. */
186
+ var FEED_STATS = [
187
+ {
188
+ id: "range",
189
+ label: "range"
190
+ },
191
+ {
192
+ id: "index",
193
+ label: "index"
194
+ },
195
+ {
196
+ id: "rows",
197
+ label: "rows"
198
+ },
199
+ {
200
+ id: "widgets",
201
+ label: "widgets"
202
+ },
203
+ {
204
+ id: "selected",
205
+ label: "selected"
206
+ },
207
+ {
208
+ id: "hits",
209
+ label: "hits"
210
+ }
211
+ ];
212
+ /**
213
+ * The scroll-quality verdict: live rate, the rate at the pass's slowest fifth, the worst frame, and
214
+ * how many frames stuttered. Only meaningful at a real keyboard — an agent's browser throttles the
215
+ * frames this counts.
216
+ *
217
+ * `jumps` are rows moving against the scroll, sampled per frame; `shifts` are rows whose offset
218
+ * changed after they were laid out; `breaks` are shifts that broke the order. All three should be
219
+ * zero on a still feed, so each is coloured the moment it is not.
220
+ */
221
+ var FRAME_STATS = [
222
+ {
223
+ id: "fps",
224
+ label: "render",
225
+ unit: "fps",
226
+ classNames: (value) => Number(value) > 0 && Number(value) < 50 && "text-warning-text"
227
+ },
228
+ {
229
+ id: "p95",
230
+ label: "p95",
231
+ unit: "ms"
232
+ },
233
+ {
234
+ id: "worst",
235
+ label: "worst",
236
+ unit: "ms"
237
+ },
238
+ {
239
+ id: "hitches",
240
+ label: "hitches",
241
+ classNames: warnAbove
242
+ },
243
+ {
244
+ id: "jumps",
245
+ label: "jumps",
246
+ classNames: warnAbove
247
+ },
248
+ {
249
+ id: "shifts",
250
+ label: "shifts",
251
+ classNames: warnAbove
252
+ },
253
+ {
254
+ id: "breaks",
255
+ label: "breaks",
256
+ classNames: warnAbove
257
+ }
258
+ ];
259
+ /**
260
+ * Everything measured about a feed, floating over it.
261
+ *
262
+ * It sits above the viewport rather than in a statusbar because a statusbar is part of the layout
263
+ * being measured: a block of readouts that grows or wraps changes the height of the scroll
264
+ * container, and every number in it would then describe a viewport the readouts themselves resized.
265
+ */
266
+ var FeedStats = ({ classNames, meter, streaming, selected = 0, hits = 0 }) => {
267
+ const { range, currentIndex, count, mountedRows, mountedWidgets, jumps, shifts, breaks, resetShifts } = useMessageList("FeedStats");
268
+ const [recording, setRecording] = useState(false);
269
+ const onRecord = useCallback(() => {
270
+ meter.record();
271
+ setRecording((value) => !value);
272
+ }, [meter]);
273
+ const values = useMemo(() => ({
274
+ range: range ? `${range.startIndex} / ${range.endIndex}` : "—",
275
+ index: `${currentIndex} / ${count}`,
276
+ rows: mountedRows,
277
+ widgets: mountedWidgets,
278
+ selected,
279
+ hits,
280
+ fps: meter.fps,
281
+ p95: meter.p95,
282
+ worst: meter.worst,
283
+ hitches: meter.hitches,
284
+ jumps: jumps.count,
285
+ shifts,
286
+ breaks
287
+ }), [
288
+ range,
289
+ currentIndex,
290
+ count,
291
+ mountedRows,
292
+ mountedWidgets,
293
+ selected,
294
+ hits,
295
+ meter,
296
+ jumps.count,
297
+ shifts,
298
+ breaks
299
+ ]);
300
+ const label = `${meter.label} — p50 ${meter.p50} · ${meter.frames} frames · ${(meter.duration / 1e3).toFixed(1)}s`;
301
+ return /* @__PURE__ */ jsxs("div", {
302
+ className: mx("z-10 absolute bottom-3 right-3 w-[12rem] grid p-2 rounded-sm border border-separator bg-base-surface text-xs text-description", classNames),
303
+ "data-testid": "feed.stats",
304
+ children: [
305
+ /* @__PURE__ */ jsxs("div", {
306
+ className: "flex items-center min-w-0",
307
+ children: [
308
+ /* @__PURE__ */ jsx("span", {
309
+ className: "grow truncate",
310
+ "data-testid": "feed.stream.state",
311
+ children: streaming ? "streaming…" : "idle"
312
+ }),
313
+ /* @__PURE__ */ jsx(IconButton, {
314
+ icon: recording ? "ph--stop--regular" : "ph--record--regular",
315
+ iconOnly: true,
316
+ label: recording ? "End the pass" : "Start a pass",
317
+ variant: "ghost",
318
+ size: 3,
319
+ "data-testid": "feed.debug.record",
320
+ onClick: onRecord
321
+ }),
322
+ /* @__PURE__ */ jsx(IconButton, {
323
+ icon: "ph--arrow-counter-clockwise--regular",
324
+ iconOnly: true,
325
+ label: "Reset counters",
326
+ variant: "ghost",
327
+ size: 3,
328
+ "data-testid": "feed.debug.reset",
329
+ onClick: resetShifts
330
+ })
331
+ ]
332
+ }),
333
+ /* @__PURE__ */ jsx(Stats, {
334
+ stats: FEED_STATS,
335
+ values
336
+ }),
337
+ /* @__PURE__ */ jsx(Stats, {
338
+ stats: FRAME_STATS,
339
+ values,
340
+ classNames: "border-t border-subdued-separator pt-1",
341
+ title: label,
342
+ "data-testid": "feed.frames"
343
+ })
344
+ ]
345
+ });
346
+ };
347
+ //#endregion
348
+ //#region src/testing/debug/sweep.ts
349
+ /**
350
+ * Travel speed (px/s) of a measured pass: about what a hard trackpad fling reaches on a 120Hz
351
+ * display, and the speed at which a reader still expects to see content rather than a blur.
352
+ *
353
+ * Not "the whole document in N seconds", which was the first attempt: 2,000 rows in six seconds is
354
+ * 40,000 px/s — some 400 rows a second, each one a CodeMirror view to mount and measure — and it
355
+ * collapsed the frame rate to 3fps on hardware that idles at 120. That measured the sweep, not the
356
+ * list.
357
+ */
358
+ var VELOCITY = 3e3;
359
+ /** Time spent travelling in each direction (ms), so a pass covers the same distance in every story. */
360
+ var LEG_DURATION = 5e3;
361
+ /**
362
+ * Scrolls an element down and back at a fixed speed.
363
+ *
364
+ * A measured pass has to be one repeatable gesture or the numbers cannot be compared between
365
+ * stories, and a 2,000-row feed is far too long to fling by hand. Fixed speed for a fixed time —
366
+ * rather than a fixed fraction of the document — so the two stories being compared travel the same
367
+ * distance even though they disagree about how tall the document is until their rows have measured.
368
+ */
369
+ var sweepScroll = (element, { velocity = VELOCITY, duration = LEG_DURATION, onDone } = {}) => {
370
+ let frame = 0;
371
+ let cancelled = false;
372
+ let start = performance.now();
373
+ let last = start;
374
+ let leg = "down";
375
+ const finish = () => {
376
+ cancelAnimationFrame(frame);
377
+ onDone?.();
378
+ };
379
+ const tick = (now) => {
380
+ if (cancelled) return;
381
+ const step = velocity * (now - last) / 1e3;
382
+ last = now;
383
+ const limit = element.scrollHeight - element.clientHeight;
384
+ element.scrollTop = Math.max(0, Math.min(element.scrollTop + (leg === "down" ? step : -step), limit));
385
+ if (now - start < duration) {
386
+ frame = requestAnimationFrame(tick);
387
+ return;
388
+ }
389
+ if (leg === "down") {
390
+ leg = "up";
391
+ start = now;
392
+ last = now;
393
+ frame = requestAnimationFrame(tick);
394
+ return;
395
+ }
396
+ finish();
397
+ };
398
+ frame = requestAnimationFrame(tick);
399
+ return () => {
400
+ cancelled = true;
401
+ finish();
402
+ };
403
+ };
404
+ //#endregion
405
+ //#region src/testing/debug/use-feed-debug.ts
406
+ /**
407
+ * Everything the harness measures itself with, in one place.
408
+ *
409
+ * Kept apart from the story deliberately: instrumentation accumulates — a meter, a sweep, a toggle,
410
+ * a label, the refs and teardown each needs — and threaded through a component it slowly becomes
411
+ * the component. As an aspect the story stays about the feed, and what is being measured can be
412
+ * read, changed or dropped in one file.
413
+ */
414
+ var useFeedDebug = ({ scenario, count, estimateSize, streaming, enabled = false }) => {
415
+ const [debug, setDebug] = useState(enabled);
416
+ const toggleDebug = useCallback(() => setDebug((value) => !value), []);
417
+ const meter = useFrameMeter({ label: useMemo(() => [
418
+ scenario ?? "mixed",
419
+ `${count} msgs`,
420
+ estimateSize ? `est ${estimateSize}` : void 0,
421
+ streaming && "streaming"
422
+ ].filter(Boolean).join(" · "), [
423
+ scenario,
424
+ count,
425
+ estimateSize,
426
+ streaming
427
+ ]) });
428
+ const viewportRef = useRef(null);
429
+ const [sweeping, setSweeping] = useState(false);
430
+ const cancelSweep = useRef(null);
431
+ const onSweep = useCallback(() => {
432
+ if (sweeping) {
433
+ cancelSweep.current?.();
434
+ return;
435
+ }
436
+ const viewport = viewportRef.current;
437
+ if (!viewport) return;
438
+ meter.record();
439
+ setSweeping(true);
440
+ cancelSweep.current = sweepScroll(viewport, { onDone: () => {
441
+ cancelSweep.current = null;
442
+ setSweeping(false);
443
+ meter.record();
444
+ } });
445
+ }, [sweeping, meter]);
446
+ useEffect(() => () => cancelSweep.current?.(), []);
447
+ return {
448
+ debug,
449
+ toggleDebug,
450
+ meter,
451
+ viewportRef,
452
+ sweeping,
453
+ onSweep
454
+ };
455
+ };
456
+ //#endregion
457
+ //#region src/testing/generator.ts
458
+ var SENDERS = ["user", "assistant"];
459
+ /**
460
+ * Synthetic feed for measuring the engine.
461
+ *
462
+ * Heights are deliberately uneven — a uniform-height feed would hide exactly the measurement
463
+ * problem the spike exists to answer.
464
+ */
465
+ var createMessages = ({ count, kinds = [
466
+ "markdown",
467
+ "markdown",
468
+ "short",
469
+ "code",
470
+ "html"
471
+ ], seed = 999 }) => {
472
+ random.seed(seed);
473
+ const start = Date.now() - count * 6e4;
474
+ return Array.from({ length: count }, (_, index) => {
475
+ const kind = kinds[index % kinds.length];
476
+ const role = SENDERS[index % SENDERS.length];
477
+ return Message.make({
478
+ created: new Date(start + index * 6e4).toISOString(),
479
+ sender: {
480
+ role,
481
+ name: role === "user" ? "Alice" : "Assistant"
482
+ },
483
+ blocks: [blockFor(kind, index)],
484
+ properties: {
485
+ subject: `Message ${index}`,
486
+ kind
487
+ }
488
+ });
489
+ });
490
+ };
491
+ var blockFor = (kind, index) => {
492
+ switch (kind) {
493
+ case "short": return {
494
+ _tag: "text",
495
+ text: `**${index}.** ${random.lorem.sentence(6)}`
496
+ };
497
+ case "code": return {
498
+ _tag: "text",
499
+ text: [
500
+ `**${index}.** ${random.lorem.sentence(8)}`,
501
+ "",
502
+ "```ts",
503
+ "const feed = useQuery(db, Query.from(feed));",
504
+ "const hits = searchFeed(messages, renderer, query);",
505
+ "```",
506
+ "",
507
+ random.lorem.sentence(12)
508
+ ].join("\n")
509
+ };
510
+ case "html": return {
511
+ _tag: "text",
512
+ mimeType: "text/html",
513
+ text: [
514
+ `<p><strong>${index}.</strong> ${random.lorem.sentence(10)}</p>`,
515
+ `<blockquote>${random.lorem.sentence(14)}</blockquote>`,
516
+ "<ul><li>attachment.pdf</li><li>invoice.csv</li></ul>"
517
+ ].join("")
518
+ };
519
+ default: return {
520
+ _tag: "text",
521
+ text: [
522
+ `**${index}.** ${random.lorem.sentence(10)}`,
523
+ "",
524
+ random.lorem.paragraph(),
525
+ "",
526
+ `- ${random.lorem.sentence(5)}`,
527
+ `- ${random.lorem.sentence(7)}`
528
+ ].join("\n")
529
+ };
530
+ }
531
+ };
532
+ //#endregion
533
+ //#region src/testing/controls.tsx
534
+ /** The floor: the row's text, and no editor at all. */
535
+ var TextItem = ({ content }) => /* @__PURE__ */ jsx("p", {
536
+ className: "py-1 text-sm",
537
+ children: String(content.data ?? "")
538
+ });
539
+ /** An editor with nothing but wrapping: no theme, no keymap, no markdown, no decoration. */
540
+ var BARE = [
541
+ EditorView.editable.of(false),
542
+ EditorState.readOnly.of(true),
543
+ EditorView.lineWrapping
544
+ ];
545
+ /** Wrapping plus the theme — the extension the isolated profile shows to be nearly free. */
546
+ var THEMED = [createBasicExtensions({
547
+ readOnly: true,
548
+ editable: false,
549
+ lineWrapping: true
550
+ }), createThemeExtensions({ themeMode: "light" })];
551
+ /** The theme plus the markdown language: parsing, but nothing rendered from what it parsed. */
552
+ var MARKDOWN = [...THEMED, createMarkdownExtensions()];
553
+ /** Everything the item has bar its highlight layer — headings, lists and links drawn as themselves. */
554
+ var DECORATED = [...MARKDOWN, decorateMarkdown()];
555
+ var BareEditorItem = ({ content }) => /* @__PURE__ */ jsx(ProbeEditor, {
556
+ text: String(content.data ?? ""),
557
+ extensions: BARE
558
+ });
559
+ var ThemedEditorItem = ({ content }) => /* @__PURE__ */ jsx(ProbeEditor, {
560
+ text: String(content.data ?? ""),
561
+ extensions: THEMED
562
+ });
563
+ var MarkdownEditorItem = ({ content }) => /* @__PURE__ */ jsx(ProbeEditor, {
564
+ text: String(content.data ?? ""),
565
+ extensions: MARKDOWN
566
+ });
567
+ var DecoratedEditorItem = ({ content }) => /* @__PURE__ */ jsx(ProbeEditor, {
568
+ text: String(content.data ?? ""),
569
+ extensions: DECORATED
570
+ });
571
+ /**
572
+ * The real item, reached the same way the probes are.
573
+ *
574
+ * The rung that separates *what the editor is configured with* from *what the component does around
575
+ * it*: everything outside this component is identical to `uniform-decorated`.
576
+ */
577
+ var MarkdownProbeItem = ({ content }) => /* @__PURE__ */ jsx(MarkdownBlock, { text: String(content.data ?? "") });
578
+ /**
579
+ * A deliberate copy of `MarkdownBlock`'s construction, reduced to the part being measured.
580
+ *
581
+ * Copied rather than shared: the point of a control is that it does not change when the thing it is
582
+ * a control for does.
583
+ */
584
+ var ProbeEditor = ({ text, extensions }) => {
585
+ const rootRef = useRef(null);
586
+ useLayoutEffect(() => {
587
+ const parent = rootRef.current;
588
+ if (!parent) return;
589
+ const view = new EditorView({
590
+ parent,
591
+ state: EditorState.create({
592
+ doc: text,
593
+ extensions
594
+ })
595
+ });
596
+ return () => view.destroy();
597
+ }, [text, extensions]);
598
+ return /* @__PURE__ */ jsx("div", { ref: rootRef });
599
+ };
600
+ //#endregion
601
+ //#region src/testing/widgets.tsx
602
+ /**
603
+ * Widgets for the block kinds an assistant turn contains.
604
+ *
605
+ * Approximations of `plugin-assistant`'s, deliberately: what the engine has to prove is that a
606
+ * per-message document can carry block widgets at all — that they mount, measure and survive
607
+ * streaming — not that these are the widgets the plugin will ship. The real ones move down when the
608
+ * plugin does.
609
+ *
610
+ * Every widget renders from the tag alone. The plugin keeps tool state beside the document and
611
+ * re-applies it after a remount; an item here is rebuilt from its message, so state that is not in
612
+ * the document has nowhere to survive.
613
+ */
614
+ /**
615
+ * A collapsible block: the header is what the reader sees while scrolling past, the body is what
616
+ * they open when they care. Reasoning and tool traces are long, incidental and frequent — rendering
617
+ * them expanded turns a chat into a log.
618
+ *
619
+ * The disclosure animates, and every frame of it is measured twice — by CodeMirror, to re-place the
620
+ * lines under the widget, and by the virtualizer, to re-place the rows under the item. That is what
621
+ * it costs for the content below to travel with the panel instead of jumping to its new place; the
622
+ * geometry stays consistent because `MarkdownBlock` re-measures the document as the widget resizes.
623
+ */
624
+ var Panel$1 = ({ stateKey, icon, title, children, classNames }) => {
625
+ const [open, setOpen] = useWidgetState(stateKey, false);
626
+ return /* @__PURE__ */ jsx("div", {
627
+ "data-testid": "feed.widget",
628
+ "data-open": open,
629
+ children: /* @__PURE__ */ jsx(TogglePanel.Root, {
630
+ open,
631
+ onChangeOpen: setOpen,
632
+ children: /* @__PURE__ */ jsxs(TogglePanel.Content, {
633
+ classNames: mx("min-h-[2.125rem] rounded border border-subdued-separator", classNames),
634
+ children: [/* @__PURE__ */ jsxs(TogglePanel.Header, {
635
+ classNames: "flex items-center gap-2 px-2 py-1 text-sm",
636
+ children: [/* @__PURE__ */ jsx("span", {
637
+ className: "grow text-description truncate",
638
+ children: title
639
+ }), /* @__PURE__ */ jsx(IconBlock, { children: /* @__PURE__ */ jsx(Icon, {
640
+ icon,
641
+ size: 4
642
+ }) })]
643
+ }), /* @__PURE__ */ jsx(TogglePanel.Body, { children: /* @__PURE__ */ jsx(TogglePanel.Viewport, {
644
+ classNames: "px-2 pb-1 text-sm",
645
+ children
646
+ }) })]
647
+ })
648
+ })
649
+ });
650
+ };
651
+ var Reasoning = ({ children, range }) => {
652
+ const text = getXmlTextChild(children ?? []) ?? "";
653
+ return /* @__PURE__ */ jsx(Panel$1, {
654
+ stateKey: `reasoning:${range?.from}`,
655
+ icon: "ph--brain--regular",
656
+ title: text.slice(0, 80),
657
+ classNames: "opacity-70",
658
+ children: /* @__PURE__ */ jsx("p", { children: text })
659
+ });
660
+ };
661
+ var Status = ({ children }) => /* @__PURE__ */ jsx("p", {
662
+ className: "px-2 py-1 text-sm text-description animate-pulse",
663
+ children: getXmlTextChild(children ?? [])
664
+ });
665
+ var ToolCall = ({ name, pending, range }) => /* @__PURE__ */ jsx(Panel$1, {
666
+ stateKey: `toolCall:${range?.from}`,
667
+ icon: pending ? "ph--circle-notch--regular" : "ph--wrench--regular",
668
+ title: `Calling ${name}`,
669
+ children: /* @__PURE__ */ jsx("p", {
670
+ className: "font-mono text-xs",
671
+ children: name
672
+ })
673
+ });
674
+ var ToolResult = ({ children, for: tool, range }) => /* @__PURE__ */ jsx(Panel$1, {
675
+ stateKey: `toolResult:${range?.from}`,
676
+ icon: "ph--check--regular",
677
+ title: `${tool} returned`,
678
+ children: /* @__PURE__ */ jsx("p", {
679
+ className: "font-mono text-xs",
680
+ children: getXmlTextChild(children ?? [])
681
+ })
682
+ });
683
+ var Suggestion = ({ children }) => /* @__PURE__ */ jsx("button", {
684
+ type: "button",
685
+ className: "me-1 px-2 py-0.5 rounded-full border border-separator text-sm",
686
+ children: getXmlTextChild(children ?? [])
687
+ });
688
+ var Select = ({ children }) => {
689
+ const options = (children ?? []).map((option) => option?._tag === "option" && getXmlTextChild(option.children)).filter(Boolean);
690
+ return /* @__PURE__ */ jsx("div", {
691
+ className: "flex flex-col gap-1 py-1",
692
+ children: options.map((option) => /* @__PURE__ */ jsx("button", {
693
+ type: "button",
694
+ className: "px-2 py-1 text-left rounded border border-separator text-sm",
695
+ children: option
696
+ }, option))
697
+ });
698
+ };
699
+ var Json = ({ children }) => /* @__PURE__ */ jsx("pre", {
700
+ className: "px-2 py-1 overflow-x-auto rounded bg-input-surface text-xs",
701
+ children: getXmlTextChild(children ?? [])
702
+ });
703
+ /**
704
+ * Floor for a panel's collapsed height, reserved before its portaled content paints.
705
+ *
706
+ * `heightMode: 'min'` is what makes this a floor rather than a pin — the default pins the box and
707
+ * clips it, so an opened panel stays shut. Without any reservation the row is measured at the height
708
+ * of an empty box and grows a frame later, which is a row moving under a reader scrolling up.
709
+ */
710
+ var COLLAPSED_HEIGHT = 42;
711
+ var chatRegistry = {
712
+ prompt: { block: true },
713
+ reasoning: {
714
+ block: true,
715
+ streaming: true,
716
+ estimatedHeight: () => COLLAPSED_HEIGHT,
717
+ heightMode: "min",
718
+ Component: Reasoning
719
+ },
720
+ status: {
721
+ block: true,
722
+ streaming: true,
723
+ estimatedHeight: () => COLLAPSED_HEIGHT,
724
+ heightMode: "min",
725
+ Component: Status
726
+ },
727
+ toolCall: {
728
+ block: true,
729
+ estimatedHeight: () => COLLAPSED_HEIGHT,
730
+ heightMode: "min",
731
+ Component: ToolCall
732
+ },
733
+ toolResult: {
734
+ block: true,
735
+ estimatedHeight: () => COLLAPSED_HEIGHT,
736
+ heightMode: "min",
737
+ Component: ToolResult
738
+ },
739
+ suggestion: {
740
+ block: false,
741
+ Component: Suggestion
742
+ },
743
+ select: {
744
+ block: true,
745
+ Component: Select
746
+ },
747
+ json: {
748
+ block: true,
749
+ Component: Json
750
+ }
751
+ };
752
+ //#endregion
753
+ //#region src/testing/scenarios.tsx
754
+ var UNIFORM_CONTROLS = {
755
+ "uniform-text": TextItem,
756
+ "uniform-bare": BareEditorItem,
757
+ "uniform-themed": ThemedEditorItem,
758
+ "uniform-markdown": MarkdownEditorItem,
759
+ "uniform-decorated": DecoratedEditorItem,
760
+ "uniform-item": MarkdownProbeItem
761
+ };
762
+ var createScenario = ({ scenario, count, seed = 999 }) => {
763
+ random.seed(seed);
764
+ switch (scenario) {
765
+ case "plain": return {
766
+ messages: createPlainMessages(count),
767
+ renderer: (message) => ({
768
+ kind: "custom",
769
+ key: "plain",
770
+ data: message.properties?.height ?? 120
771
+ }),
772
+ Chrome: PlainChrome,
773
+ Custom: PlainItem,
774
+ stickyBottom: true,
775
+ estimateSize: (message) => Number(message.properties?.height ?? 120) + PLAIN_CHROME
776
+ };
777
+ case "uniform": return {
778
+ messages: createUniformMessages(count),
779
+ renderer: defaultRenderer,
780
+ Chrome: PlainChrome,
781
+ stickyBottom: true,
782
+ estimateSize: UNIFORM_HEIGHT
783
+ };
784
+ case "uniform-text":
785
+ case "uniform-bare":
786
+ case "uniform-themed":
787
+ case "uniform-markdown":
788
+ case "uniform-decorated":
789
+ case "uniform-item": return {
790
+ messages: createUniformMessages(count),
791
+ renderer: (message) => ({
792
+ kind: "custom",
793
+ key: scenario,
794
+ data: Message.extractText(message)
795
+ }),
796
+ Chrome: PlainChrome,
797
+ Custom: UNIFORM_CONTROLS[scenario],
798
+ stickyBottom: true,
799
+ estimateSize: 84
800
+ };
801
+ case "assistant": return {
802
+ messages: createAssistantMessages(count),
803
+ renderer: chatRenderer,
804
+ registry: chatRegistry,
805
+ Chrome: AssistantChrome,
806
+ stickyBottom: true,
807
+ estimateSize: estimateAssistantRow,
808
+ isAnchor: (message) => message.sender.role === "user"
809
+ };
810
+ case "email": return {
811
+ messages: createEmailMessages(count),
812
+ renderer: defaultRenderer,
813
+ Chrome: EmailChrome,
814
+ stickyBottom: false,
815
+ estimateSize: 220
816
+ };
817
+ case "thread": return {
818
+ messages: createThreadMessages(count),
819
+ renderer: defaultRenderer,
820
+ Chrome: ThreadChrome,
821
+ stickyBottom: true,
822
+ estimateSize: 64
823
+ };
824
+ case "comments": return {
825
+ messages: createCommentMessages(count),
826
+ renderer: defaultRenderer,
827
+ Chrome: CommentChrome,
828
+ stickyBottom: false,
829
+ estimateSize: 96
830
+ };
831
+ case "transcript": return {
832
+ messages: createTranscriptMessages(count),
833
+ renderer: defaultRenderer,
834
+ Chrome: TranscriptChrome,
835
+ stickyBottom: true,
836
+ estimateSize: 48
837
+ };
838
+ }
839
+ };
840
+ /** Chrome around every assistant row: the sender line, the padding and the separator. */
841
+ var ROW_CHROME = 46;
842
+ /** Height of a wrapped line of body text, and how many characters fit on one at the story's width. */
843
+ var LINE_HEIGHT = 24;
844
+ var LINE_CHARS = 90;
845
+ /** A collapsed panel: reasoning, a tool call, a tool result. */
846
+ var PANEL_HEIGHT = 50;
847
+ /**
848
+ * What a row will measure, from the message alone.
849
+ *
850
+ * Rough on purpose — it is an estimate, and its only job is to be close enough that measuring the row
851
+ * does not move the rows below it. Text is counted in wrapped lines, blank lines between blocks are
852
+ * counted, and every block that renders as a widget is counted at its collapsed height.
853
+ */
854
+ var estimateAssistantRow = (message) => {
855
+ let height = ROW_CHROME;
856
+ for (const block of message.blocks) switch (block._tag) {
857
+ case "text": {
858
+ const paragraphs = block.text.split("\n\n");
859
+ for (const paragraph of paragraphs) height += Math.max(1, Math.ceil(paragraph.length / LINE_CHARS)) * LINE_HEIGHT;
860
+ break;
861
+ }
862
+ case "suggestion":
863
+ height += 32;
864
+ break;
865
+ case "select":
866
+ height += block.options.length * 34;
867
+ break;
868
+ default: height += PANEL_HEIGHT;
869
+ }
870
+ return height;
871
+ };
872
+ /** Fixed heights, cycling so the feed is uneven — but known in advance and never corrected. */
873
+ var PLAIN_HEIGHTS = [
874
+ 64,
875
+ 120,
876
+ 200,
877
+ 88,
878
+ 320,
879
+ 56
880
+ ];
881
+ /**
882
+ * What the chrome adds to a plain row: the separator beneath it.
883
+ *
884
+ * The estimate has to include this. A control scenario whose estimate is the *content* height is off
885
+ * by the chrome on every single row, and the list corrects each one — which looks exactly like the
886
+ * defect the control exists to rule out.
887
+ */
888
+ var PLAIN_CHROME = 1;
889
+ var createPlainMessages = (count) => Array.from({ length: count }, (_, index) => Message.make({
890
+ created: at(index),
891
+ sender: {
892
+ role: index % 2 === 0 ? "user" : "assistant",
893
+ name: index % 2 === 0 ? "Alice" : "Assistant"
894
+ },
895
+ blocks: [{
896
+ _tag: "text",
897
+ text: random.lorem.sentence(index % 8)
898
+ }],
899
+ properties: { height: PLAIN_HEIGHTS[index % PLAIN_HEIGHTS.length] }
900
+ }));
901
+ /**
902
+ * What one uniform row measures, chrome included.
903
+ *
904
+ * Exact on purpose, and checked against the DOM rather than guessed: this scenario's whole job is to
905
+ * be the case where the estimate is right, and it was declared at 84 against a real 24. A viewport
906
+ * filled from an estimate three times too large covers a third of the screen once the rows measure,
907
+ * so the feed arrived in two visible pieces — in the one story that exists to have nothing wrong
908
+ * with it.
909
+ */
910
+ var UNIFORM_HEIGHT = 24;
911
+ /** Every row the same document, so a single estimate is exactly right for all of them. */
912
+ var createUniformMessages = (count) => Array.from({ length: count }, (_, index) => Message.make({
913
+ created: at(index),
914
+ sender: {
915
+ role: index % 2 === 0 ? "user" : "assistant",
916
+ name: index % 2 === 0 ? "Alice" : "Assistant"
917
+ },
918
+ blocks: [{
919
+ _tag: "text",
920
+ text: random.lorem.sentence(index % 16 === 0 ? 100 : 1)
921
+ }]
922
+ }));
923
+ var NAMES = [
924
+ "Alice",
925
+ "Bob",
926
+ "Charlie",
927
+ "Dana"
928
+ ];
929
+ var at = (index, step = 6e4) => (/* @__PURE__ */ new Date(Date.now() - (2e3 - index) * step)).toISOString();
930
+ /** A turn: the reader's prompt, then an answer carrying the block kinds a model actually emits. */
931
+ var createAssistantMessages = (count) => Array.from({ length: count }, (_, index) => {
932
+ if (index % 2 === 0) return Message.make({
933
+ created: at(index),
934
+ sender: {
935
+ role: "user",
936
+ name: "Alice"
937
+ },
938
+ blocks: [{
939
+ _tag: "text",
940
+ text: random.lorem.paragraph()
941
+ }]
942
+ });
943
+ const blocks = [{
944
+ _tag: "reasoning",
945
+ reasoningText: random.lorem.paragraph()
946
+ }];
947
+ if (index % 4 === 1) {
948
+ const toolCallId = `tool-${index}`;
949
+ blocks.push({
950
+ _tag: "toolCall",
951
+ toolCallId,
952
+ name: "search",
953
+ input: "{}",
954
+ providerExecuted: false
955
+ });
956
+ blocks.push({
957
+ _tag: "toolResult",
958
+ toolCallId,
959
+ name: "search",
960
+ result: random.lorem.paragraphs(2),
961
+ providerExecuted: false
962
+ });
963
+ }
964
+ blocks.push({
965
+ _tag: "text",
966
+ text: answerText(index)
967
+ });
968
+ if (index % 6 === 3) {
969
+ blocks.push({
970
+ _tag: "suggestion",
971
+ text: "Tell me more"
972
+ });
973
+ blocks.push({
974
+ _tag: "suggestion",
975
+ text: "Show the sources"
976
+ });
977
+ }
978
+ if (index % 8 === 5) blocks.push({
979
+ _tag: "select",
980
+ options: [
981
+ "Option 1",
982
+ "Option 2",
983
+ "Option 3"
984
+ ]
985
+ });
986
+ return Message.make({
987
+ created: at(index),
988
+ sender: {
989
+ role: "assistant",
990
+ name: "Assistant"
991
+ },
992
+ blocks
993
+ });
994
+ });
995
+ var answerText = (index) => [
996
+ `**${index}.** ${random.lorem.sentence(10)}`,
997
+ "",
998
+ random.lorem.paragraph(),
999
+ "",
1000
+ `- ${random.lorem.sentence(5)}`,
1001
+ `- ${random.lorem.sentence(7)}`
1002
+ ].join("\n");
1003
+ /** An email conversation: HTML bodies, quoted history, and a subject that never changes. */
1004
+ var createEmailMessages = (count) => {
1005
+ const subject = `Re: ${random.lorem.words(4)}`;
1006
+ return Array.from({ length: count }, (_, index) => {
1007
+ const from = NAMES[index % NAMES.length];
1008
+ return Message.make({
1009
+ created: at(index, 36e5),
1010
+ sender: {
1011
+ role: "user",
1012
+ name: from,
1013
+ email: `${from.toLowerCase()}@example.com`
1014
+ },
1015
+ blocks: [{
1016
+ _tag: "text",
1017
+ mimeType: "text/html",
1018
+ text: [
1019
+ `<p>${random.lorem.paragraph()}</p>`,
1020
+ `<p>${random.lorem.paragraph()}</p>`,
1021
+ "<ul><li>attachment.pdf</li><li>invoice.csv</li></ul>",
1022
+ `<blockquote>${random.lorem.sentence(16)}</blockquote>`
1023
+ ].join("")
1024
+ }],
1025
+ properties: { subject }
1026
+ });
1027
+ });
1028
+ };
1029
+ /** Human chat: short turns, many of them, often several in a row from one speaker. */
1030
+ var createThreadMessages = (count) => Array.from({ length: count }, (_, index) => {
1031
+ const name = NAMES[Math.floor(index / 3) % NAMES.length];
1032
+ return Message.make({
1033
+ created: at(index, 3e4),
1034
+ sender: {
1035
+ role: "user",
1036
+ name
1037
+ },
1038
+ blocks: [{
1039
+ _tag: "text",
1040
+ text: random.lorem.sentence(random.number.int({
1041
+ min: 4,
1042
+ max: 20
1043
+ }))
1044
+ }]
1045
+ });
1046
+ });
1047
+ /** Comments: each anchored to a range of a document, and resolvable. */
1048
+ var createCommentMessages = (count) => Array.from({ length: count }, (_, index) => Message.make({
1049
+ created: at(index, 12e4),
1050
+ sender: {
1051
+ role: "user",
1052
+ name: NAMES[index % NAMES.length]
1053
+ },
1054
+ blocks: [{
1055
+ _tag: "text",
1056
+ text: random.lorem.sentence(random.number.int({
1057
+ min: 6,
1058
+ max: 24
1059
+ }))
1060
+ }],
1061
+ properties: {
1062
+ anchor: random.lorem.sentence(6),
1063
+ resolved: index % 7 === 0
1064
+ }
1065
+ }));
1066
+ /** Transcription: one utterance per message, seconds apart, arriving while the reader watches. */
1067
+ var createTranscriptMessages = (count) => Array.from({ length: count }, (_, index) => Message.make({
1068
+ created: at(index, 6e3),
1069
+ sender: {
1070
+ role: "user",
1071
+ name: NAMES[index % 2]
1072
+ },
1073
+ blocks: [{
1074
+ _tag: "text",
1075
+ text: random.lorem.sentence(random.number.int({
1076
+ min: 5,
1077
+ max: 18
1078
+ }))
1079
+ }]
1080
+ }));
1081
+ /**
1082
+ * A row of a known height: the content is a box, not a document.
1083
+ *
1084
+ * The height is the point — it is declared, never measured — so it is stated in the row rather than
1085
+ * left to be inferred, and the text sits at the top like every other scenario's does.
1086
+ */
1087
+ var PlainItem = ({ content, message }) => {
1088
+ const height = Number(content.data ?? 120);
1089
+ return /* @__PURE__ */ jsxs("div", {
1090
+ className: "flex flex-col gap-1 py-2 text-sm",
1091
+ style: { height },
1092
+ children: [/* @__PURE__ */ jsxs("div", {
1093
+ className: "flex items-center gap-2 text-xs text-description",
1094
+ children: [/* @__PURE__ */ jsx("span", {
1095
+ className: "font-medium",
1096
+ children: message.sender.name
1097
+ }), /* @__PURE__ */ jsxs("span", {
1098
+ className: "tabular-nums",
1099
+ children: [height, "px"]
1100
+ })]
1101
+ }), /* @__PURE__ */ jsx("p", { children: Message.extractText(message) })]
1102
+ });
1103
+ };
1104
+ /** No hover controls and no separators: nothing here can change a row's height. */
1105
+ /**
1106
+ * The index is shown in the gutter because these are the control scenarios: when a row moves, the
1107
+ * first question is *which* row, and reading it off the screen beats hunting for it in the DOM.
1108
+ */
1109
+ var PlainChrome = ({ index, children }) => /* @__PURE__ */ jsxs(Row, {
1110
+ classNames: "py-0 grid grid-cols-[3rem_1fr] gap-2",
1111
+ children: [/* @__PURE__ */ jsx("div", {
1112
+ className: "self-start flex items-center h-[1lh] text-base leading-normal",
1113
+ children: /* @__PURE__ */ jsx("span", {
1114
+ className: "text-xs text-subdued tabular-nums",
1115
+ children: index
1116
+ })
1117
+ }), /* @__PURE__ */ jsx("div", {
1118
+ className: "min-w-0",
1119
+ children
1120
+ })]
1121
+ });
1122
+ var timeOf = (message, options) => new Date(message.created).toLocaleTimeString([], options ?? {
1123
+ hour: "2-digit",
1124
+ minute: "2-digit"
1125
+ });
1126
+ var Row = ({ children, classNames }) => /* @__PURE__ */ jsx("div", {
1127
+ className: mx("group relative px-2 py-2", classNames),
1128
+ "data-testid": "feed.message",
1129
+ children
1130
+ });
1131
+ /**
1132
+ * Assistant: the reader's prompts and the model's answers are framed differently, because they are
1133
+ * different kinds of thing — a prompt is an instruction that can be rewound to, an answer is a
1134
+ * result that can be forked from.
1135
+ *
1136
+ * A prompt carries its rewind toolbar underneath; an answer carries its timestamp at the foot, where
1137
+ * it reads as when the answer finished rather than when the turn began. Both are always in flow — a
1138
+ * control that appears on hover changes the row's height, and a pointer travelling down a scrolling
1139
+ * list then moves every row below it.
1140
+ */
1141
+ var AssistantChrome = ({ message, index, selected, children }) => {
1142
+ const prompt = message.sender.role === "user";
1143
+ return /* @__PURE__ */ jsx(Row, {
1144
+ classNames: mx(selected && "bg-hover-surface"),
1145
+ children: prompt ? /* @__PURE__ */ jsxs("div", {
1146
+ className: "min-w-0",
1147
+ children: [/* @__PURE__ */ jsx("div", {
1148
+ className: "ps-2 pe-2 pt-1 pb-1 border-s-2 border-accent-bg rounded-sm bg-input-surface",
1149
+ children
1150
+ }), /* @__PURE__ */ jsxs("div", {
1151
+ className: "flex items-center gap-1 pt-1 text-xs text-description opacity-0 transition-opacity group-hover:opacity-100",
1152
+ children: [
1153
+ /* @__PURE__ */ jsx(IconButton, {
1154
+ icon: "ph--arrow-counter-clockwise--regular",
1155
+ iconOnly: true,
1156
+ label: "Rewind",
1157
+ variant: "ghost",
1158
+ size: 3
1159
+ }),
1160
+ /* @__PURE__ */ jsx(IconButton, {
1161
+ icon: "ph--git-branch--regular",
1162
+ iconOnly: true,
1163
+ label: "Fork",
1164
+ variant: "ghost",
1165
+ size: 3
1166
+ }),
1167
+ /* @__PURE__ */ jsx("span", { children: timeOf(message) }),
1168
+ /* @__PURE__ */ jsxs("span", {
1169
+ className: "text-subdued",
1170
+ children: ["#", index]
1171
+ })
1172
+ ]
1173
+ })]
1174
+ }) : /* @__PURE__ */ jsxs("div", {
1175
+ className: "min-w-0",
1176
+ children: [children, /* @__PURE__ */ jsxs("div", {
1177
+ className: "flex items-center gap-2 pt-1 text-xs text-description opacity-0 transition-opacity group-hover:opacity-100",
1178
+ children: [
1179
+ /* @__PURE__ */ jsx("span", {
1180
+ className: "font-medium",
1181
+ children: message.sender.name
1182
+ }),
1183
+ /* @__PURE__ */ jsx("span", { children: timeOf(message) }),
1184
+ /* @__PURE__ */ jsxs("span", {
1185
+ className: "text-subdued",
1186
+ children: ["#", index]
1187
+ }),
1188
+ /* @__PURE__ */ jsx("span", { className: "grow" }),
1189
+ /* @__PURE__ */ jsx(IconButton, {
1190
+ icon: "ph--arrow-bend-up-left--regular",
1191
+ iconOnly: true,
1192
+ label: "Reply",
1193
+ variant: "ghost",
1194
+ size: 3
1195
+ })
1196
+ ]
1197
+ })]
1198
+ })
1199
+ });
1200
+ };
1201
+ /** Email: a header of its own — sender, address, date — and a card-like body. */
1202
+ var EmailChrome = ({ message, children }) => /* @__PURE__ */ jsxs(Row, {
1203
+ classNames: "border-b border-separator",
1204
+ children: [
1205
+ /* @__PURE__ */ jsxs("div", {
1206
+ className: "flex items-baseline gap-2",
1207
+ children: [
1208
+ /* @__PURE__ */ jsx("span", {
1209
+ className: "font-medium",
1210
+ children: message.sender.name
1211
+ }),
1212
+ /* @__PURE__ */ jsx("span", {
1213
+ className: "text-xs text-description",
1214
+ children: message.sender.email
1215
+ }),
1216
+ /* @__PURE__ */ jsx("span", { className: "grow" }),
1217
+ /* @__PURE__ */ jsx("span", {
1218
+ className: "text-xs text-description",
1219
+ children: new Date(message.created).toLocaleString()
1220
+ })
1221
+ ]
1222
+ }),
1223
+ /* @__PURE__ */ jsx("p", {
1224
+ className: "text-xs text-description",
1225
+ children: String(message.properties?.subject ?? "")
1226
+ }),
1227
+ /* @__PURE__ */ jsx("div", {
1228
+ className: "mt-1",
1229
+ children
1230
+ })
1231
+ ]
1232
+ });
1233
+ /** Human chat: an avatar, and consecutive turns from one speaker read as one block. */
1234
+ var ThreadChrome = ({ message, children }) => /* @__PURE__ */ jsxs(Row, {
1235
+ classNames: "grid grid-cols-[2rem_1fr] gap-2",
1236
+ children: [/* @__PURE__ */ jsx("div", {
1237
+ className: "w-6 h-6 rounded-full bg-input-surface grid place-items-center text-xs",
1238
+ children: message.sender.name?.[0]
1239
+ }), /* @__PURE__ */ jsxs("div", {
1240
+ className: "min-w-0",
1241
+ children: [/* @__PURE__ */ jsxs("div", {
1242
+ className: "flex items-center gap-2 text-xs text-description",
1243
+ children: [/* @__PURE__ */ jsx("span", {
1244
+ className: "font-medium",
1245
+ children: message.sender.name
1246
+ }), /* @__PURE__ */ jsx("span", { children: timeOf(message) })]
1247
+ }), children]
1248
+ })]
1249
+ });
1250
+ /** Comments: the quoted anchor above the comment, and a resolve control. */
1251
+ var CommentChrome = ({ message, children }) => /* @__PURE__ */ jsxs(Row, {
1252
+ classNames: mx("border-b border-subdued-separator", message.properties?.resolved && "opacity-50"),
1253
+ children: [
1254
+ /* @__PURE__ */ jsx("p", {
1255
+ className: "mb-1 ps-2 border-s-2 border-separator text-xs text-description line-clamp-1",
1256
+ children: String(message.properties?.anchor ?? "")
1257
+ }),
1258
+ /* @__PURE__ */ jsxs("div", {
1259
+ className: "flex items-center gap-2 text-xs text-description",
1260
+ children: [
1261
+ /* @__PURE__ */ jsx("span", {
1262
+ className: "font-medium",
1263
+ children: message.sender.name
1264
+ }),
1265
+ /* @__PURE__ */ jsx("span", { children: timeOf(message) }),
1266
+ /* @__PURE__ */ jsx("span", { className: "grow" }),
1267
+ /* @__PURE__ */ jsx(IconButton, {
1268
+ icon: message.properties?.resolved ? "ph--check-circle--regular" : "ph--circle--regular",
1269
+ iconOnly: true,
1270
+ label: "Resolve",
1271
+ variant: "ghost",
1272
+ size: 3
1273
+ })
1274
+ ]
1275
+ }),
1276
+ children
1277
+ ]
1278
+ });
1279
+ /** Transcription: a timestamp and speaker in the gutter, and no separators — it reads as one text. */
1280
+ var TranscriptChrome = ({ message, children }) => /* @__PURE__ */ jsxs(Row, {
1281
+ classNames: "grid grid-cols-[5rem_1fr] gap-2 py-0.5",
1282
+ children: [/* @__PURE__ */ jsx("div", {
1283
+ className: "text-xs text-description tabular-nums",
1284
+ children: timeOf(message, {
1285
+ hour: "2-digit",
1286
+ minute: "2-digit",
1287
+ second: "2-digit"
1288
+ })
1289
+ }), /* @__PURE__ */ jsxs("div", {
1290
+ className: "min-w-0",
1291
+ children: [/* @__PURE__ */ jsx("span", {
1292
+ className: "me-2 text-xs font-medium text-description",
1293
+ children: message.sender.name
1294
+ }), children]
1295
+ })]
1296
+ });
1297
+ //#endregion
1298
+ //#region src/testing/stream.ts
1299
+ /**
1300
+ * Word-by-word stream, the shape a model emits: chunks of a few words at an uneven cadence.
1301
+ *
1302
+ * Whitespace is preserved as its own token so a chunk boundary never fuses two words, which would
1303
+ * corrupt the markdown the item is parsing mid-stream.
1304
+ */
1305
+ async function* textStream(text, options = {}) {
1306
+ const { chunkDelay = 120, variance = .4, wordsPerChunk = 4 } = options;
1307
+ const chunkSize = Math.max(1, Math.floor(wordsPerChunk));
1308
+ const tokens = text.match(/\S+|\s+/g) ?? [];
1309
+ let index = 0;
1310
+ while (index < tokens.length) {
1311
+ const chunk = [];
1312
+ let words = 0;
1313
+ while (index < tokens.length && words < chunkSize) {
1314
+ const token = tokens[index++];
1315
+ chunk.push(token);
1316
+ if (token.trim()) words++;
1317
+ }
1318
+ yield chunk.join("");
1319
+ await new Promise((resolve) => setTimeout(resolve, chunkDelay * (1 + (Math.random() - .5) * variance * 2)));
1320
+ }
1321
+ }
1322
+ /**
1323
+ * A plausible assistant answer: prose, a list, a code block and a closing paragraph — enough
1324
+ * structure that the item re-parses markdown as the text arrives, rather than growing one flat
1325
+ * paragraph.
1326
+ */
1327
+ var createAnswer = () => [
1328
+ random.lorem.paragraph(),
1329
+ "",
1330
+ `## ${random.lorem.sentence(3)}`,
1331
+ "",
1332
+ random.lorem.paragraph(),
1333
+ "",
1334
+ `- **${random.lorem.words(2)}** — ${random.lorem.sentence(8)}`,
1335
+ `- **${random.lorem.words(2)}** — ${random.lorem.sentence(10)}`,
1336
+ `- **${random.lorem.words(2)}** — ${random.lorem.sentence(6)}`,
1337
+ "",
1338
+ "```ts",
1339
+ "const feed = useQuery(db, Query.from(feed));",
1340
+ "const hits = searchFeed(messages, renderer, query);",
1341
+ "```",
1342
+ "",
1343
+ random.lorem.paragraph()
1344
+ ].join("\n");
1345
+ //#endregion
1346
+ //#region src/testing/turn.ts
1347
+ /**
1348
+ * One assistant turn as the blocks a model actually emits, arriving in order.
1349
+ *
1350
+ * The text-only stream exercises the easy half of reconciliation: a tail that only ever grows, which
1351
+ * the item appends. A real turn is harder in three ways, and each is a separate way for an item to
1352
+ * be rebuilt instead of extended —
1353
+ *
1354
+ * 1. a **status** block appears first and is removed once work begins, so the document shrinks;
1355
+ * 2. **reasoning** streams inside an unclosed tag and is closed when it completes, which rewrites
1356
+ * the end of the document rather than appending to it;
1357
+ * 3. a **tool call** lands whole, then its **result** arrives as a second block after it.
1358
+ *
1359
+ * Yields the complete block array at each step; the caller replaces the message's blocks with it.
1360
+ */
1361
+ async function* streamTurn({ chunkDelay = 120, wordsPerChunk = 4 } = {}) {
1362
+ const pause = () => new Promise((resolve) => setTimeout(resolve, chunkDelay));
1363
+ const status = {
1364
+ _tag: "status",
1365
+ statusText: "Thinking…",
1366
+ pending: true
1367
+ };
1368
+ yield [status];
1369
+ await pause();
1370
+ let reasoning = "";
1371
+ for await (const chunk of textStream(random.lorem.paragraph(), {
1372
+ chunkDelay,
1373
+ wordsPerChunk
1374
+ })) {
1375
+ reasoning += chunk;
1376
+ yield [status, {
1377
+ _tag: "reasoning",
1378
+ reasoningText: reasoning,
1379
+ pending: true
1380
+ }];
1381
+ }
1382
+ const reasoned = {
1383
+ _tag: "reasoning",
1384
+ reasoningText: reasoning
1385
+ };
1386
+ yield [reasoned];
1387
+ await pause();
1388
+ const toolCallId = `tool-${random.number.int({
1389
+ min: 1e3,
1390
+ max: 9999
1391
+ })}`;
1392
+ const call = {
1393
+ _tag: "toolCall",
1394
+ toolCallId,
1395
+ name: "search",
1396
+ input: "{}",
1397
+ providerExecuted: false,
1398
+ pending: true
1399
+ };
1400
+ yield [reasoned, call];
1401
+ await pause();
1402
+ const called = {
1403
+ ...call,
1404
+ pending: false
1405
+ };
1406
+ const result = {
1407
+ _tag: "toolResult",
1408
+ toolCallId,
1409
+ name: "search",
1410
+ result: random.lorem.sentence(12),
1411
+ providerExecuted: false
1412
+ };
1413
+ yield [
1414
+ reasoned,
1415
+ called,
1416
+ result
1417
+ ];
1418
+ await pause();
1419
+ let text = "";
1420
+ for await (const chunk of textStream(createAnswer(), {
1421
+ chunkDelay,
1422
+ wordsPerChunk
1423
+ })) {
1424
+ text += chunk;
1425
+ yield [
1426
+ reasoned,
1427
+ called,
1428
+ result,
1429
+ {
1430
+ _tag: "text",
1431
+ text,
1432
+ pending: true
1433
+ }
1434
+ ];
1435
+ }
1436
+ yield [
1437
+ reasoned,
1438
+ called,
1439
+ result,
1440
+ {
1441
+ _tag: "text",
1442
+ text
1443
+ },
1444
+ {
1445
+ _tag: "suggestion",
1446
+ text: "Tell me more"
1447
+ },
1448
+ {
1449
+ _tag: "suggestion",
1450
+ text: "Show the sources"
1451
+ }
1452
+ ];
1453
+ }
1454
+ //#endregion
1455
+ //#region src/testing/FeedStory.tsx
1456
+ /** Pause between one answer finishing and the next question arriving. */
1457
+ var TURN_DELAY = 800;
1458
+ /** A turn that is only a growing text tail: what every scenario but the assistant chat produces. */
1459
+ async function* textTurn(options) {
1460
+ let text = "";
1461
+ for await (const chunk of textStream(createAnswer(), options)) {
1462
+ text += chunk;
1463
+ yield [{
1464
+ _tag: "text",
1465
+ text,
1466
+ pending: true
1467
+ }];
1468
+ }
1469
+ yield [{
1470
+ _tag: "text",
1471
+ text
1472
+ }];
1473
+ }
1474
+ var makeQuestion = () => Message.make({
1475
+ sender: {
1476
+ role: "user",
1477
+ name: "Alice"
1478
+ },
1479
+ blocks: [{
1480
+ _tag: "text",
1481
+ text: random.lorem.sentence(8)
1482
+ }]
1483
+ });
1484
+ var makeAnswer = () => Message.make({
1485
+ sender: {
1486
+ role: "assistant",
1487
+ name: "Assistant"
1488
+ },
1489
+ blocks: [{
1490
+ _tag: "text",
1491
+ text: ""
1492
+ }]
1493
+ });
1494
+ /**
1495
+ * Per-message chrome, supplied by the host rather than the engine — the thing a single thread-wide
1496
+ * document cannot do without injecting widgets into its own markdown.
1497
+ */
1498
+ var TestChrome = ({ message, index, selected, onSelect, children }) => {
1499
+ const role = message.sender.role ?? "user";
1500
+ const time = new Date(message.created).toLocaleTimeString([], {
1501
+ hour: "2-digit",
1502
+ minute: "2-digit"
1503
+ });
1504
+ return /* @__PURE__ */ jsxs("div", {
1505
+ className: mx("group relative grid grid-cols-[2rem_1fr] gap-2 px-2 py-2", selected && "bg-hover-surface"),
1506
+ "data-testid": "feed.message",
1507
+ children: [
1508
+ /* @__PURE__ */ jsx("div", {
1509
+ className: "flex flex-col items-center gap-1",
1510
+ children: /* @__PURE__ */ jsx(Input.Root, { children: /* @__PURE__ */ jsx(Input.Checkbox, {
1511
+ checked: selected,
1512
+ onCheckedChange: () => onSelect(message.id, true),
1513
+ "data-testid": "feed.message.select"
1514
+ }) })
1515
+ }),
1516
+ /* @__PURE__ */ jsxs("div", {
1517
+ className: "absolute right-1 top-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100",
1518
+ children: [
1519
+ /* @__PURE__ */ jsx(IconButton, {
1520
+ icon: "ph--git-branch--regular",
1521
+ iconOnly: true,
1522
+ label: "Fork",
1523
+ variant: "ghost",
1524
+ size: 3
1525
+ }),
1526
+ /* @__PURE__ */ jsx(IconButton, {
1527
+ icon: "ph--arrow-counter-clockwise--regular",
1528
+ iconOnly: true,
1529
+ label: "Rewind",
1530
+ variant: "ghost",
1531
+ size: 3
1532
+ }),
1533
+ /* @__PURE__ */ jsx(IconButton, {
1534
+ icon: "ph--arrow-bend-up-left--regular",
1535
+ iconOnly: true,
1536
+ label: "Reply",
1537
+ variant: "ghost",
1538
+ size: 3
1539
+ })
1540
+ ]
1541
+ }),
1542
+ /* @__PURE__ */ jsxs("div", {
1543
+ className: "min-w-0",
1544
+ children: [/* @__PURE__ */ jsxs("div", {
1545
+ className: "flex items-center gap-2 text-xs text-description",
1546
+ children: [
1547
+ /* @__PURE__ */ jsx("span", {
1548
+ className: "font-medium",
1549
+ children: message.sender.name ?? role
1550
+ }),
1551
+ /* @__PURE__ */ jsx("span", { children: time }),
1552
+ /* @__PURE__ */ jsxs("span", {
1553
+ className: "text-subdued",
1554
+ children: ["#", index]
1555
+ })
1556
+ ]
1557
+ }), children]
1558
+ })
1559
+ ]
1560
+ });
1561
+ };
1562
+ /**
1563
+ * The one renderer every `MessageList` story uses; the stories differ only by args.
1564
+ *
1565
+ * Seeded history, a search over the model, message-set selection, and a play control that streams
1566
+ * turns into the tail until stopped — one harness, so a story is a set of arguments rather than
1567
+ * another component to keep in step.
1568
+ */
1569
+ var FeedStory = ({ scenario, scrollPastEnd, count = 0, debug: debugProp, streaming: streamingProp = false, wordsPerChunk = 4, chunkDelay = 120, estimateSize }) => {
1570
+ const definition = useMemo(() => scenario ? createScenario({
1571
+ scenario,
1572
+ count
1573
+ }) : void 0, [scenario, count]);
1574
+ const [messages, setMessages] = useState(() => definition?.messages ?? createMessages({ count }));
1575
+ const [streaming, setStreaming] = useState(streamingProp);
1576
+ const [answerId, setAnswerId] = useState();
1577
+ const [selectedIds, setSelectedIds] = useState(/* @__PURE__ */ new Set());
1578
+ const [copied, setCopied] = useState(null);
1579
+ const [query, setQuery] = useState("");
1580
+ const hits = useMemo(() => searchFeed(messages, defaultRenderer, query), [messages, query]);
1581
+ const decorations = useMemo(() => hits.map(({ messageId, offset, length }) => ({
1582
+ id: messageId,
1583
+ range: {
1584
+ offset,
1585
+ length
1586
+ },
1587
+ kind: "search"
1588
+ })), [hits]);
1589
+ const isAnchor = definition?.isAnchor;
1590
+ const model = useFeedModel(messages, { stops: isAnchor ?? "message" });
1591
+ const { debug, toggleDebug, meter, viewportRef, sweeping, onSweep } = useFeedDebug({
1592
+ scenario,
1593
+ count,
1594
+ estimateSize,
1595
+ streaming,
1596
+ enabled: debugProp
1597
+ });
1598
+ const handleAppend = useCallback(() => setMessages((prev) => [...prev, makeQuestion()]), []);
1599
+ const handleReset = useCallback(() => {
1600
+ setStreaming(false);
1601
+ setAnswerId(void 0);
1602
+ setMessages(definition?.messages ?? createMessages({ count }));
1603
+ }, [count, definition]);
1604
+ const handleCopy = useCallback(() => {
1605
+ if (messages.length < 3) return;
1606
+ setCopied(sliceFeed(messages, defaultRenderer, {
1607
+ from: {
1608
+ messageId: messages[0].id,
1609
+ offset: 0
1610
+ },
1611
+ to: {
1612
+ messageId: messages[2].id,
1613
+ offset: 20
1614
+ }
1615
+ }));
1616
+ }, [messages]);
1617
+ useEffect(() => {
1618
+ if (!streaming) return;
1619
+ let cancelled = false;
1620
+ (async () => {
1621
+ while (!cancelled) {
1622
+ const answer = makeAnswer();
1623
+ setMessages((prev) => [
1624
+ ...prev,
1625
+ makeQuestion(),
1626
+ answer
1627
+ ]);
1628
+ setAnswerId(answer.id);
1629
+ const turn = scenario === "assistant" ? streamTurn({
1630
+ wordsPerChunk,
1631
+ chunkDelay
1632
+ }) : textTurn({
1633
+ wordsPerChunk,
1634
+ chunkDelay
1635
+ });
1636
+ for await (const blocks of turn) {
1637
+ if (cancelled) return;
1638
+ setMessages((prev) => prev.map((message) => message.id === answer.id ? {
1639
+ ...message,
1640
+ blocks
1641
+ } : message));
1642
+ }
1643
+ setAnswerId(void 0);
1644
+ await new Promise((resolve) => setTimeout(resolve, TURN_DELAY));
1645
+ }
1646
+ })();
1647
+ return () => {
1648
+ cancelled = true;
1649
+ setMessages((prev) => {
1650
+ const last = prev.at(-1);
1651
+ return last && last.sender.role === "assistant" && !Message.extractText(last).length ? prev.slice(0, -1) : prev;
1652
+ });
1653
+ };
1654
+ }, [
1655
+ streaming,
1656
+ wordsPerChunk,
1657
+ chunkDelay,
1658
+ scenario
1659
+ ]);
1660
+ const onSelect = useCallback((id, additive) => {
1661
+ setSelectedIds((current) => {
1662
+ const next = new Set(additive ? current : []);
1663
+ if (additive && next.has(id)) next.delete(id);
1664
+ else next.add(id);
1665
+ return next;
1666
+ });
1667
+ }, []);
1668
+ return /* @__PURE__ */ jsx(DecorationsProvider, {
1669
+ decorations,
1670
+ children: /* @__PURE__ */ jsx(ItemSelectionProvider, {
1671
+ selectedIds,
1672
+ onSelect,
1673
+ children: /* @__PURE__ */ jsxs(MessageList.Root, {
1674
+ model,
1675
+ renderer: definition?.renderer,
1676
+ debug,
1677
+ Chrome: definition?.Chrome ?? TestChrome,
1678
+ Custom: definition?.Custom,
1679
+ registry: definition?.registry,
1680
+ estimateSize: estimateSize ?? definition?.estimateSize,
1681
+ stickyBottom: definition?.stickyBottom ?? true,
1682
+ scrollPastEnd,
1683
+ children: [/* @__PURE__ */ jsxs(Panel.Root, { children: [/* @__PURE__ */ jsx(Panel.Toolbar, {
1684
+ asChild: true,
1685
+ children: /* @__PURE__ */ jsxs(Toolbar.Root, { children: [
1686
+ /* @__PURE__ */ jsx(IconButton, {
1687
+ icon: streaming ? "ph--stop--regular" : "ph--play--regular",
1688
+ iconOnly: true,
1689
+ label: streaming ? "Stop" : "Start",
1690
+ "data-testid": "feed.stream.toggle",
1691
+ onClick: () => setStreaming((value) => !value)
1692
+ }),
1693
+ /* @__PURE__ */ jsx(IconButton, {
1694
+ icon: "ph--plus--regular",
1695
+ iconOnly: true,
1696
+ label: "Add message",
1697
+ "data-testid": "feed.stream.append",
1698
+ onClick: handleAppend
1699
+ }),
1700
+ /* @__PURE__ */ jsx(IconButton, {
1701
+ icon: "ph--trash--regular",
1702
+ iconOnly: true,
1703
+ label: "Reset",
1704
+ onClick: handleReset
1705
+ }),
1706
+ /* @__PURE__ */ jsx(IconButton, {
1707
+ icon: debug ? "ph--bounding-box--fill" : "ph--bounding-box--regular",
1708
+ iconOnly: true,
1709
+ label: debug ? "Hide block outlines" : "Show block outlines",
1710
+ "data-testid": "feed.debug.toggle",
1711
+ onClick: toggleDebug
1712
+ }),
1713
+ /* @__PURE__ */ jsx(Toolbar.Separator, {}),
1714
+ /* @__PURE__ */ jsx(Input.Root, { children: /* @__PURE__ */ jsx(Input.TextInput, {
1715
+ placeholder: "Search…",
1716
+ value: query,
1717
+ onChange: (event) => setQuery(event.target.value),
1718
+ "data-testid": "feed.search"
1719
+ }) }),
1720
+ /* @__PURE__ */ jsx(FindButton, { hits }),
1721
+ /* @__PURE__ */ jsx(IconButton, {
1722
+ icon: "ph--copy--regular",
1723
+ iconOnly: true,
1724
+ label: "Copy range",
1725
+ onClick: handleCopy
1726
+ }),
1727
+ /* @__PURE__ */ jsx(IconButton, {
1728
+ icon: sweeping ? "ph--stop--regular" : "ph--arrows-down-up--regular",
1729
+ iconOnly: true,
1730
+ label: sweeping ? "Stop sweep" : "Sweep (measure a pass)",
1731
+ "data-testid": "feed.sweep",
1732
+ onClick: onSweep
1733
+ }),
1734
+ /* @__PURE__ */ jsx("div", { className: "grow" }),
1735
+ /* @__PURE__ */ jsx(NavButtons, {})
1736
+ ] })
1737
+ }), /* @__PURE__ */ jsxs(Panel.Content, {
1738
+ classNames: "relative dx-container",
1739
+ children: [/* @__PURE__ */ jsx("div", {
1740
+ className: "z-10 absolute left-0 top-0 bottom-0 grid grid-rows-[1fr_4fr_1fr] justify-center",
1741
+ children: /* @__PURE__ */ jsx(FeedOutline, {
1742
+ classNames: "row-start-2",
1743
+ messages
1744
+ })
1745
+ }), /* @__PURE__ */ jsx(MessageList.Viewport, {
1746
+ classNames: "absolute inset-0",
1747
+ padding: true,
1748
+ ref: viewportRef
1749
+ })]
1750
+ })] }), /* @__PURE__ */ jsx(FeedStats, {
1751
+ meter,
1752
+ streaming,
1753
+ selected: selectedIds.size,
1754
+ hits: hits.length
1755
+ })]
1756
+ })
1757
+ })
1758
+ });
1759
+ };
1760
+ /**
1761
+ * The outline of a feed: one tick per prompt, in message-index space.
1762
+ *
1763
+ * `OutlineMarker.range` is "the document's own position space", which for a feed is the message
1764
+ * index — so a marker is `[index, index + 1)` and the visible range is the mounted window. No
1765
+ * pixel offsets are involved, which is what makes this survive rows whose heights are still
1766
+ * estimates.
1767
+ */
1768
+ var FeedOutline = ({ classNames, messages }) => {
1769
+ const { currentIndex, scrollToIndex } = useMessageList("FeedOutline");
1770
+ const markers = useMemo(() => {
1771
+ return messages.map((message, index) => ({
1772
+ message,
1773
+ index
1774
+ })).filter(({ message }) => message.sender.role === "user").map(({ message, index }) => ({
1775
+ id: message.id,
1776
+ title: `#${index} · ${message.sender.name ?? message.sender.role}`,
1777
+ description: Message.extractText(message).replace(/<[^>]*>/g, " ").split("\n").map((line) => line.trim()).find(Boolean)?.slice(0, 160),
1778
+ range: {
1779
+ from: index,
1780
+ to: index + 1
1781
+ }
1782
+ }));
1783
+ }, [messages]);
1784
+ const current = useMemo(() => {
1785
+ return ([...markers].reverse().find(({ range: { from } }) => from <= currentIndex) ?? markers[0])?.range;
1786
+ }, [markers, currentIndex]);
1787
+ const handleSelect = useCallback((marker) => scrollToIndex(marker.range.from, {
1788
+ align: "start",
1789
+ behavior: "smooth"
1790
+ }), [scrollToIndex]);
1791
+ if (!markers.length) return /* @__PURE__ */ jsx("div", {});
1792
+ return /* @__PURE__ */ jsx(Outline, {
1793
+ classNames: ["justify-self-center", classNames],
1794
+ markers,
1795
+ visibleRange: current,
1796
+ onSelect: handleSelect
1797
+ });
1798
+ };
1799
+ /** Scroll-to-hit lives in the toolbar, outside the viewport — the reason Root is headless. */
1800
+ var FindButton = ({ hits }) => {
1801
+ const { scrollToIndex } = useMessageList("FindButton");
1802
+ const handleFind = useCallback(() => {
1803
+ const hit = hits[0];
1804
+ if (hit) scrollToIndex(hit.index, {
1805
+ align: "center",
1806
+ behavior: "smooth"
1807
+ });
1808
+ }, [hits, scrollToIndex]);
1809
+ return /* @__PURE__ */ jsx(IconButton, {
1810
+ icon: "ph--magnifying-glass--regular",
1811
+ iconOnly: true,
1812
+ label: "Find",
1813
+ onClick: handleFind
1814
+ });
1815
+ };
1816
+ /** Move by one message, or to either end, from the list's cursor (what the arrow keys also move). */
1817
+ var NavButtons = () => {
1818
+ const { currentIndex: current, count, navigation, scrollToIndex, scrollToBottom } = useMessageList("NavButtons");
1819
+ const step = navigation.step;
1820
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
1821
+ /* @__PURE__ */ jsx(IconButton, {
1822
+ icon: "ph--arrow-line-up--regular",
1823
+ iconOnly: true,
1824
+ label: "First message",
1825
+ variant: "ghost",
1826
+ disabled: current <= 0,
1827
+ "data-testid": "feed.nav.top",
1828
+ onClick: () => scrollToIndex(0, {
1829
+ align: "start",
1830
+ behavior: "smooth"
1831
+ })
1832
+ }),
1833
+ /* @__PURE__ */ jsx(IconButton, {
1834
+ icon: "ph--caret-up--regular",
1835
+ iconOnly: true,
1836
+ label: "Previous message",
1837
+ variant: "ghost",
1838
+ disabled: current <= 0,
1839
+ "data-testid": "feed.nav.back",
1840
+ onClick: () => step(-1)
1841
+ }),
1842
+ /* @__PURE__ */ jsx(IconButton, {
1843
+ icon: "ph--caret-down--regular",
1844
+ iconOnly: true,
1845
+ label: "Next message",
1846
+ variant: "ghost",
1847
+ disabled: current >= count - 1,
1848
+ "data-testid": "feed.nav.forward",
1849
+ onClick: () => step(1)
1850
+ }),
1851
+ /* @__PURE__ */ jsx(IconButton, {
1852
+ icon: "ph--arrow-line-down--regular",
1853
+ iconOnly: true,
1854
+ label: "Last message",
1855
+ variant: "ghost",
1856
+ disabled: count === 0,
1857
+ "data-testid": "feed.nav.bottom",
1858
+ onClick: () => scrollToBottom({ behavior: "smooth" })
1859
+ })
1860
+ ] });
1861
+ };
1862
+ //#endregion
1863
+ //#region src/testing/MessageWindow.tsx
1864
+ var DefaultChrome = ({ children }) => /* @__PURE__ */ jsx(Fragment$1, { children });
1865
+ var MessageWindow = ({ messages, renderer = defaultRenderer, Chrome = DefaultChrome, Custom, estimateSize = 120, gutter, sticky, reserve, onChange, controllerRef }) => {
1866
+ const [selectedIds, setSelectedIds] = useState(/* @__PURE__ */ new Set());
1867
+ const onSelect = useCallback((id) => setSelectedIds((current) => new Set(current).add(id)), []);
1868
+ const model = useListModel(messages, (message) => message.id);
1869
+ const extents = useMemo(() => ({ of: (index) => {
1870
+ const message = messages[index];
1871
+ if (!message) return typeof estimateSize === "number" ? estimateSize : 120;
1872
+ return typeof estimateSize === "function" ? estimateSize(message, index) : estimateSize;
1873
+ } }), [messages, estimateSize]);
1874
+ return /* @__PURE__ */ jsx(Window, {
1875
+ classNames: "h-full",
1876
+ model,
1877
+ extents,
1878
+ sticky,
1879
+ reserve,
1880
+ onChange,
1881
+ controllerRef,
1882
+ children: (index) => {
1883
+ const message = messages[index];
1884
+ if (!message) return null;
1885
+ return /* @__PURE__ */ jsx(Column.Root, {
1886
+ gutter,
1887
+ children: /* @__PURE__ */ jsx(Column.Center, { children: /* @__PURE__ */ jsx(Chrome, {
1888
+ message,
1889
+ index,
1890
+ selected: selectedIds.has(message.id),
1891
+ onSelect,
1892
+ children: /* @__PURE__ */ jsx(Item, {
1893
+ content: renderer(message),
1894
+ message,
1895
+ Custom
1896
+ })
1897
+ }) })
1898
+ });
1899
+ }
1900
+ });
1901
+ };
1902
+ /**
1903
+ * The item's content, resolved without the list.
1904
+ *
1905
+ * `MessageList.Item` reads the renderer, the registry, the hits and the widget census from
1906
+ * `MessageList.Root`'s context, so it cannot be rendered outside the very thing this exists to
1907
+ * replace. Repeated here rather than refactored: making the item take its dependencies as props is
1908
+ * the right change and belongs in the retrofit, not in a harness written to measure it.
1909
+ */
1910
+ var Item = ({ content, message, Custom }) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
1911
+ content.kind === "markdown" && /* @__PURE__ */ jsx(MarkdownBlock, { text: content.text }),
1912
+ content.kind === "html" && /* @__PURE__ */ jsx(HtmlBlock, { html: content.html }),
1913
+ content.kind === "custom" && Custom && /* @__PURE__ */ jsx(Custom, {
1914
+ content,
1915
+ message
1916
+ })
1917
+ ] });
1918
+ /** Kept for the stories that drive it; the ref is the same handle `placement/*` uses. */
1919
+ var useMessageWindow = () => useRef(null);
1920
+ //#endregion
1921
+ export { BareEditorItem, DecoratedEditorItem, FeedStats, FeedStory, MarkdownEditorItem, MarkdownProbeItem, MessageWindow, TextItem, ThemedEditorItem, chatRegistry, createAnswer, createMessages, createScenario, streamTurn, sweepScroll, textStream, useFeedDebug, useMessageWindow };
1922
+
1923
+ //# sourceMappingURL=testing.mjs.map