@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,2228 @@
1
+ import { createContext, memo, useCallback, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
3
+ import { EditorState, StateEffect, StateField } from "@codemirror/state";
4
+ import { Decoration, EditorView } from "@codemirror/view";
5
+ import DOMPurify from "dompurify";
6
+ import { createPortal } from "react-dom";
7
+ import { Column, Popover, ScrollArea, composable, composableProps, setRef, useThemeContext } from "@dxos/react-ui";
8
+ import { createBasicExtensions, createMarkdownExtensions, createThemeExtensions, decorateMarkdown, extendedMarkdown, xmlBlockDecoration, xmlFormatting, xmlTags } from "@dxos/ui-editor";
9
+ import { createContext as createContext$1 } from "@radix-ui/react-context";
10
+ import * as Atom from "effect/unstable/reactivity/Atom";
11
+ import * as Registry from "effect/unstable/reactivity/AtomRegistry";
12
+ import { mx } from "@dxos/ui-theme";
13
+ //#region src/hooks/follow.ts
14
+ var DEFAULT_MAX_SPEED = 2;
15
+ var DEFAULT_ACCELERATION = 12;
16
+ var DEFAULT_DECELERATION = 6;
17
+ /**
18
+ * Gentler than the ramp up, and deliberately so.
19
+ *
20
+ * Braking distance is `v²/2d`, so matching the two rates sheds full speed in three rows — a travel
21
+ * of any length then cruises to within a few rows of the target and stops, which reads as an abrupt
22
+ * halt however correct the curve is. At a third of the acceleration the landing occupies nine rows
23
+ * and a second and a half, which is long enough to see.
24
+ */
25
+ var DEFAULT_ROW_HEIGHT = 120;
26
+ /** Within this distance the follow has arrived; below it the animation would only jitter. */
27
+ var ARRIVED = .5;
28
+ /**
29
+ * Speed for the next frame of a follow.
30
+ *
31
+ * Ramps up to `maxSpeed` and back down so the travel starts and ends gently rather than jumping.
32
+ * The cap is whichever is lower: the ceiling, or the speed from which the remaining distance can
33
+ * still be shed at `acceleration` (`v = sqrt(2·a·d)`) — that second term is what turns the arrival
34
+ * into a deceleration instead of a stop, and what keeps a growing target at cruise speed.
35
+ *
36
+ * Acceleration is rate-limited so a start is gradual; **deceleration is not**, because the braking
37
+ * curve is already a smooth ramp to zero and easing onto it instead leaves the speed above the
38
+ * curve for the whole approach — the follow then runs out of distance while still moving and the
39
+ * arrival reads as a stop rather than a landing.
40
+ */
41
+ var stepVelocity = ({ velocity, distance, dt, maxSpeed, acceleration, deceleration = acceleration }) => {
42
+ const braking = Math.sqrt(2 * deceleration * Math.max(distance, 0));
43
+ const desired = Math.min(maxSpeed, braking);
44
+ return velocity < desired ? Math.min(desired, velocity + acceleration * dt) : desired;
45
+ };
46
+ /**
47
+ * **Not wired into `MessageList` at present.** The placement engine follows the tail by holding an
48
+ * intent and correcting the offset, which arrives instantly; this class is the animated travel the
49
+ * old engine used, kept with its tests because the glide is worth having back and nothing about it
50
+ * was wrong. Wiring it is a decision about feel, not a repair.
51
+ */
52
+ /**
53
+ * Keeps an element scrolled to its bottom while its content grows.
54
+ *
55
+ * Native `scrollTo({ behavior: 'smooth' })` restarts its easing on every call, so a target that
56
+ * moves every chunk never leaves the start of the curve — the motion reads as a stutter. This
57
+ * carries velocity across frames instead: it accelerates as a stream begins, cruises while content
58
+ * keeps arriving, and decelerates onto the tail when it stops.
59
+ *
60
+ * `@dxos/ui-editor`'s `createCrawler` follows a streaming CodeMirror document the same way, with a
61
+ * critically-damped spring. The two are deliberately separate: that one is bound to `view.scrollDOM`
62
+ * and driven by editor effects, where a feed owns its own scroll container. The models differ in
63
+ * feel — a spring's speed falls off with distance, where this holds a ceiling and brakes onto the
64
+ * target, which keeps a fast stream at a constant rate rather than an ever-widening lag.
65
+ */
66
+ var ScrollFollower = class {
67
+ _element;
68
+ #maxSpeed;
69
+ #acceleration;
70
+ #deceleration;
71
+ #rowHeight;
72
+ #trailing;
73
+ #frame;
74
+ #velocity = 0;
75
+ #timestamp = 0;
76
+ #braking = false;
77
+ constructor(_element, { maxSpeed = DEFAULT_MAX_SPEED, acceleration = DEFAULT_ACCELERATION, deceleration = DEFAULT_DECELERATION, rowHeight = () => DEFAULT_ROW_HEIGHT, trailing = () => 0 } = {}) {
78
+ this._element = _element;
79
+ this.#maxSpeed = maxSpeed;
80
+ this.#acceleration = acceleration;
81
+ this.#deceleration = deceleration;
82
+ this.#rowHeight = rowHeight;
83
+ this.#trailing = trailing;
84
+ }
85
+ /**
86
+ * The bottom of the last row, which is not the bottom of the document.
87
+ *
88
+ * A feed may reserve empty space below its last row so that row can be brought to the top of the
89
+ * viewport. That space is somewhere the reader may go and nowhere the follow should: chasing the
90
+ * element's own maximum walks the feed off the end of the conversation and keeps going.
91
+ */
92
+ get #bottom() {
93
+ return this._element.scrollHeight - this.#trailing() - this._element.clientHeight;
94
+ }
95
+ get running() {
96
+ return this.#frame !== void 0;
97
+ }
98
+ /** Current speed, rows/s. Exposed so a harness can show the ramp. */
99
+ get velocity() {
100
+ return this.#velocity / Math.max(this.#rowHeight(), 1);
101
+ }
102
+ /** Starts (or continues) travelling towards the bottom. Safe to call on every content change. */
103
+ start() {
104
+ this.#braking = false;
105
+ if (this.#frame !== void 0) return;
106
+ this.#timestamp = performance.now();
107
+ this.#frame = requestAnimationFrame(this.#tick);
108
+ }
109
+ /**
110
+ * Coasts to a halt, shedding speed at `deceleration` — what a stream ending or a stop control
111
+ * means. Cutting the animation instead would halt mid-glide, which is the one motion the reader
112
+ * cannot have caused and so reads as a fault.
113
+ */
114
+ stop() {
115
+ if (this.#frame === void 0) {
116
+ this.#velocity = 0;
117
+ return;
118
+ }
119
+ this.#braking = true;
120
+ }
121
+ /**
122
+ * Halts immediately, discarding the velocity — the reader has taken over, or the list is going
123
+ * away. Coasting here would fight a hand already on the scrollbar.
124
+ */
125
+ cancel() {
126
+ if (this.#frame !== void 0) {
127
+ cancelAnimationFrame(this.#frame);
128
+ this.#frame = void 0;
129
+ }
130
+ this.#braking = false;
131
+ this.#velocity = 0;
132
+ }
133
+ /** Jumps to the bottom with no animation. */
134
+ jump() {
135
+ this.cancel();
136
+ this._element.scrollTop = Math.max(0, this.#bottom);
137
+ }
138
+ #tick = (timestamp) => {
139
+ const dt = Math.min((timestamp - this.#timestamp) / 1e3, .05);
140
+ this.#timestamp = timestamp;
141
+ const target = Math.max(0, this.#bottom);
142
+ const distance = target - this._element.scrollTop;
143
+ if (this.#braking) {
144
+ const rowHeight = Math.max(this.#rowHeight(), 1);
145
+ this.#velocity = Math.max(0, this.#velocity - this.#deceleration * rowHeight * dt);
146
+ if (this.#velocity <= 0 || distance <= ARRIVED) {
147
+ this.#frame = void 0;
148
+ this.#velocity = 0;
149
+ this.#braking = false;
150
+ return;
151
+ }
152
+ this._element.scrollTop = Math.min(target, this._element.scrollTop + this.#velocity * dt);
153
+ this.#frame = requestAnimationFrame(this.#tick);
154
+ return;
155
+ }
156
+ if (distance <= ARRIVED) {
157
+ this._element.scrollTop = target;
158
+ this.#frame = void 0;
159
+ this.#velocity = 0;
160
+ return;
161
+ }
162
+ const rowHeight = Math.max(this.#rowHeight(), 1);
163
+ this.#velocity = stepVelocity({
164
+ velocity: this.#velocity,
165
+ distance,
166
+ dt,
167
+ maxSpeed: this.#maxSpeed * rowHeight,
168
+ acceleration: this.#acceleration * rowHeight,
169
+ deceleration: this.#deceleration * rowHeight
170
+ });
171
+ this._element.scrollTop = Math.min(target, this._element.scrollTop + this.#velocity * dt);
172
+ this.#frame = requestAnimationFrame(this.#tick);
173
+ };
174
+ };
175
+ //#endregion
176
+ //#region src/hooks/useDecorations.tsx
177
+ var DecorationsContext = createContext(/* @__PURE__ */ new Map());
178
+ /**
179
+ * THE PATTERN for cross-cutting per-item data (SPEC §Aspects), not a feed feature: a provider at
180
+ * the host carries what producers made, and an item asks for its own by id. The list neither knows
181
+ * nor routes — `hits` left its API entirely. Grouped once per change rather than filtered per item,
182
+ * so a long feed stays O(decorations).
183
+ */
184
+ var DecorationsProvider = ({ decorations, children }) => {
185
+ const byId = useMemo(() => {
186
+ const map = /* @__PURE__ */ new Map();
187
+ for (const decoration of decorations ?? []) {
188
+ const list = map.get(decoration.id) ?? [];
189
+ list.push(decoration);
190
+ map.set(decoration.id, list);
191
+ }
192
+ return map;
193
+ }, [decorations]);
194
+ return /* @__PURE__ */ jsx(DecorationsContext.Provider, {
195
+ value: byId,
196
+ children
197
+ });
198
+ };
199
+ var NONE = [];
200
+ /** An item's own decorations. Items work without a provider — they are simply undecorated. */
201
+ var useDecorations = (id) => {
202
+ return useContext(DecorationsContext).get(id) ?? NONE;
203
+ };
204
+ //#endregion
205
+ //#region src/hooks/useFeedNavigation.ts
206
+ /**
207
+ * The one navigation seam (SPEC: useFeedNavigation): toolbar buttons, arrow keys, the outline rail
208
+ * and the minimap all call this, so they cannot disagree about what a step is.
209
+ *
210
+ * Stepping is from the row containing the scroll offset. The stop above it begins strictly higher
211
+ * and the stop below strictly lower, so every press moves the viewport — an index compared against
212
+ * a position cannot promise that while positions are still being measured. Steps glide (corrections
213
+ * move the window, not the scroll, so nothing cancels the animation); far jumps are instant, since
214
+ * a glide across unmounted rows is a journey over a blank screen.
215
+ *
216
+ * Distinct from react-ui-list's `useListNavigation`, which is focus traversal over fully-mounted
217
+ * rows — a different problem in the same naming family.
218
+ */
219
+ var useFeedNavigation = ({ controller, stops, current, count, scrollPastEnd, onNavigate }) => {
220
+ const jumpTo = useCallback((index, behavior = "auto") => {
221
+ onNavigate?.(index);
222
+ const align = !scrollPastEnd && index >= count() - 1 ? "end" : "start";
223
+ controller.current?.scrollToIndex(index, align, behavior);
224
+ }, [
225
+ controller,
226
+ count,
227
+ scrollPastEnd,
228
+ onNavigate
229
+ ]);
230
+ return {
231
+ step: useCallback((delta) => {
232
+ const all = stops();
233
+ if (!all.length) return;
234
+ const at = current();
235
+ const next = delta > 0 ? all.find(({ index }) => index > at) ?? all[all.length - 1] : [...all].reverse().find(({ index }) => index < at) ?? all[0];
236
+ jumpTo(next.index, "smooth");
237
+ }, [
238
+ stops,
239
+ current,
240
+ jumpTo
241
+ ]),
242
+ jumpTo,
243
+ first: useCallback(() => jumpTo(0), [jumpTo]),
244
+ last: useCallback(() => jumpTo(count() - 1), [jumpTo, count])
245
+ };
246
+ };
247
+ //#endregion
248
+ //#region src/hooks/useFollow.ts
249
+ /** Distance from the end within which the reader counts as being at it. */
250
+ var STICKY_THRESHOLD = 32;
251
+ /** How long after a gesture a scroll is still attributable to the reader. */
252
+ var GESTURE_WINDOW = 300;
253
+ /**
254
+ * The sticky tail as an intent (SPEC: useFollow) — a policy over the window, not part of it.
255
+ *
256
+ * Withdrawn by one thing only: a scroll that moves *backwards*. That is the reader; a correction
257
+ * never moves the offset back, it moves the end away. Re-deriving the intent from proximity on
258
+ * every commit cannot survive its own corrections — measuring the tail grows the document under a
259
+ * reader who has not moved, the gap opens past any threshold worth having, and the follow reads
260
+ * that as the reader leaving and disengages for good (`bridge/Tail`, the second defect).
261
+ *
262
+ * Reusable for any streaming container — transcripts, logs — because nothing here knows about
263
+ * messages: it needs a scroller, a placement, and the two numbers that say the content grew.
264
+ */
265
+ var useFollow = ({ scrollerRef, placement, extent, count, axis = "block", reserve = 0, enabled, glide = true }) => {
266
+ const following = useRef(!!enabled);
267
+ const wasEnabled = useRef(enabled);
268
+ if (wasEnabled.current !== enabled) {
269
+ wasEnabled.current = enabled;
270
+ following.current = !!enabled;
271
+ }
272
+ /**
273
+ * Where the reader has to be for the last row's rendered edge to rest on the viewport's.
274
+ *
275
+ * From the row's own rectangle when it is mounted, and from the arithmetic only when it is not:
276
+ * measured extents are rounded to integers and rendered rows are fractional, so over a mounted
277
+ * window the sum drifts from the layout by tens of pixels — an "arrived" that the reader can see
278
+ * is short. The rect already includes the window's transform, so the delta is exact.
279
+ */
280
+ const endTarget = useCallback(() => {
281
+ const scroller = scrollerRef.current;
282
+ if (!scroller) return 0;
283
+ const last = scroller.querySelector(`[data-index="${count - 1}"]`);
284
+ if (last) {
285
+ const current = axis === "block" ? scroller.scrollTop : scroller.scrollLeft;
286
+ const delta = axis === "block" ? last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom : last.getBoundingClientRect().right - scroller.getBoundingClientRect().right;
287
+ return Math.max(0, Math.round(current + delta));
288
+ }
289
+ return placement.endOffset();
290
+ }, [
291
+ count,
292
+ axis,
293
+ placement,
294
+ scrollerRef.current
295
+ ]);
296
+ const follower = useMemo(() => {
297
+ const scroller = scrollerRef.current;
298
+ if (!scroller || axis !== "block") return;
299
+ return new ScrollFollower(scroller, {
300
+ maxSpeed: 8,
301
+ acceleration: 32,
302
+ deceleration: 10,
303
+ trailing: () => scroller.scrollHeight - scroller.clientHeight - endTarget(),
304
+ rowHeight: () => {
305
+ const { first, last } = placement.layout();
306
+ const sizes = [];
307
+ for (let index = first; index <= last; index++) sizes.push(placement.extentOf(index));
308
+ sizes.sort((a, b) => a - b);
309
+ const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 100;
310
+ return Math.min(median, scroller.clientHeight || median);
311
+ }
312
+ });
313
+ }, [
314
+ scrollerRef.current,
315
+ axis,
316
+ placement,
317
+ endTarget
318
+ ]);
319
+ useLayoutEffect(() => () => follower?.cancel(), [follower]);
320
+ const lastOffset = useRef(0);
321
+ const gestureAt = useRef(0);
322
+ useLayoutEffect(() => {
323
+ const scroller = scrollerRef.current;
324
+ if (!scroller || !enabled) return;
325
+ const onGesture = () => {
326
+ gestureAt.current = performance.now();
327
+ };
328
+ const onScroll = () => {
329
+ const current = axis === "block" ? scroller.scrollTop : scroller.scrollLeft;
330
+ const viewport = axis === "block" ? scroller.clientHeight : scroller.clientWidth;
331
+ const total = axis === "block" ? scroller.scrollHeight : scroller.scrollWidth;
332
+ const back = current < lastOffset.current - 1;
333
+ lastOffset.current = current;
334
+ if (total - reserve - current - viewport <= STICKY_THRESHOLD) following.current = true;
335
+ else if (back && performance.now() - gestureAt.current < GESTURE_WINDOW) {
336
+ following.current = false;
337
+ follower?.cancel();
338
+ }
339
+ };
340
+ scroller.addEventListener("scroll", onScroll, { passive: true });
341
+ for (const event of [
342
+ "wheel",
343
+ "touchmove",
344
+ "keydown",
345
+ "pointerdown"
346
+ ]) scroller.addEventListener(event, onGesture, { passive: true });
347
+ return () => {
348
+ scroller.removeEventListener("scroll", onScroll);
349
+ for (const event of [
350
+ "wheel",
351
+ "touchmove",
352
+ "keydown",
353
+ "pointerdown"
354
+ ]) scroller.removeEventListener(event, onGesture);
355
+ };
356
+ }, [
357
+ scrollerRef.current,
358
+ enabled,
359
+ axis,
360
+ reserve,
361
+ follower
362
+ ]);
363
+ const positioned = useRef(false);
364
+ useLayoutEffect(() => {
365
+ const scroller = scrollerRef.current;
366
+ if (!enabled || !following.current || !count || !scroller) return;
367
+ const target = endTarget();
368
+ const gap = target - (axis === "block" ? scroller.scrollTop : scroller.scrollLeft);
369
+ if (Math.abs(gap) <= 1) {
370
+ positioned.current = true;
371
+ return;
372
+ }
373
+ const viewport = axis === "block" ? scroller.clientHeight : scroller.clientWidth;
374
+ if (positioned.current && glide && follower && gap > 0 && gap <= viewport) {
375
+ follower.start();
376
+ return;
377
+ }
378
+ follower?.cancel();
379
+ placement.scrollTo(target);
380
+ lastOffset.current = target;
381
+ if (axis === "block") scroller.scrollTop = target;
382
+ else scroller.scrollLeft = target;
383
+ }, [
384
+ enabled,
385
+ count,
386
+ extent,
387
+ placement,
388
+ axis,
389
+ glide,
390
+ follower,
391
+ endTarget,
392
+ scrollerRef.current
393
+ ]);
394
+ return { onNavigate: useCallback((index) => {
395
+ const wants = index >= count - 1;
396
+ following.current = !!enabled && wants;
397
+ if (!wants) follower?.cancel();
398
+ }, [
399
+ enabled,
400
+ count,
401
+ follower
402
+ ]) };
403
+ };
404
+ //#endregion
405
+ //#region src/hooks/useItemSelection.tsx
406
+ var ItemSelectionContext = createContext({});
407
+ /**
408
+ * The item-side hook of the decoration pattern applied to selection (SPEC: useItemSelection): an
409
+ * item asks for its own `{ selected, toggle }` by id instead of the list threading `selectedIds`
410
+ * through props. Selection is model-space ids, so virtualization is untouched: an unmounted item is
411
+ * still selected, and mounts knowing it.
412
+ *
413
+ * The provider is deliberately the thinnest thing that works — a set and a callback. Mode policies
414
+ * (single | multi | range) belong to `react-ui-list`'s `useListSelection`, which a host composes
415
+ * *above* this provider; teaching this package about modes would be rebuilding that hook.
416
+ */
417
+ var ItemSelectionProvider = ({ selectedIds, onSelect, children }) => /* @__PURE__ */ jsx(ItemSelectionContext.Provider, {
418
+ value: {
419
+ selectedIds,
420
+ onSelect
421
+ },
422
+ children
423
+ });
424
+ /**
425
+ * The whole selection value, for a component that renders many items in one pass.
426
+ *
427
+ * A row loop cannot call `useItemSelection` per row — hooks do not run in loops — so the viewport
428
+ * reads the value once and derives per-row state; item-shaped components use the per-id hook.
429
+ */
430
+ var useItemSelectionValue = () => useContext(ItemSelectionContext);
431
+ var useItemSelection = (id) => {
432
+ const { selectedIds, onSelect } = useContext(ItemSelectionContext);
433
+ const toggle = useCallback((additive = true) => onSelect?.(id, additive), [id, onSelect]);
434
+ return {
435
+ selected: selectedIds?.has(id) ?? false,
436
+ toggle
437
+ };
438
+ };
439
+ /** Reads the additive intent off a gesture, so callers do not each invent the modifier rule. */
440
+ var isAdditive = (event) => event.metaKey || event.ctrlKey || event.shiftKey;
441
+ //#endregion
442
+ //#region src/components/Block/selection-group.ts
443
+ /** Creates a group; one per feed, provided to its items by `MessageList.Root`. */
444
+ var createSelectionGroup = () => {
445
+ const views = /* @__PURE__ */ new Set();
446
+ return {
447
+ register: (view) => {
448
+ views.add(view);
449
+ return () => views.delete(view);
450
+ },
451
+ claim: (view) => {
452
+ for (const other of views) {
453
+ if (other === view || other.state.selection.main.empty) continue;
454
+ other.dispatch({ selection: { anchor: 0 } });
455
+ }
456
+ }
457
+ };
458
+ };
459
+ var SelectionGroupContext = createContext(void 0);
460
+ /** The enclosing feed's selection group, or a private one when an item is used standalone. */
461
+ var useSelectionGroup = () => {
462
+ const provided = useContext(SelectionGroupContext);
463
+ const fallback = useMemo(createSelectionGroup, []);
464
+ return provided ?? fallback;
465
+ };
466
+ //#endregion
467
+ //#region src/components/Block/highlight.ts
468
+ var setHighlights = StateEffect.define();
469
+ var mark = Decoration.mark({ class: "dx-feed-hit" });
470
+ var clamp = (offset, length) => Math.max(0, Math.min(offset, length));
471
+ /**
472
+ * Highlights pushed in from the model.
473
+ *
474
+ * Search runs over the feed's projection rather than per-view (most messages are never mounted), so
475
+ * the item receives ranges instead of owning search state.
476
+ */
477
+ var highlights = StateField.define({
478
+ create: () => Decoration.none,
479
+ update: (value, tr) => {
480
+ for (const effect of tr.effects) if (effect.is(setHighlights)) {
481
+ const length = tr.state.doc.length;
482
+ return Decoration.set(effect.value.map(([from, to]) => [clamp(from, length), clamp(to, length)]).filter(([from, to]) => from < to).map(([from, to]) => mark.range(from, to)), true);
483
+ }
484
+ return tr.docChanged ? value.map(tr.changes) : value;
485
+ },
486
+ provide: (field) => EditorView.decorations.from(field)
487
+ });
488
+ var highlightTheme = EditorView.baseTheme({ ".dx-feed-hit": {
489
+ backgroundColor: "var(--dx-accentFill)",
490
+ color: "var(--dx-accentFillText)",
491
+ borderRadius: "2px"
492
+ } });
493
+ //#endregion
494
+ //#region src/components/Block/widget-state.tsx
495
+ var WidgetStateContext = createContext(void 0);
496
+ /** Scopes a widget's key to its message, so two messages' widgets cannot collide. */
497
+ var WidgetScopeContext = createContext("");
498
+ var WidgetStateProvider = ({ store, children }) => /* @__PURE__ */ jsx(WidgetStateContext.Provider, {
499
+ value: store,
500
+ children
501
+ });
502
+ var WidgetScopeProvider = ({ scope, children }) => /* @__PURE__ */ jsx(WidgetScopeContext.Provider, {
503
+ value: scope,
504
+ children
505
+ });
506
+ /** Creates the store a feed's widgets share. One per `MessageList.Root`. */
507
+ var createWidgetStateStore = () => {
508
+ const values = /* @__PURE__ */ new Map();
509
+ return {
510
+ read: (key) => values.get(key),
511
+ write: (key, value) => {
512
+ values.set(key, value);
513
+ }
514
+ };
515
+ };
516
+ /**
517
+ * `useState` for a widget, keyed so it survives the row being destroyed and rebuilt.
518
+ *
519
+ * The key must identify the widget within its message — its tag and position in the document, say —
520
+ * and is scoped to the message automatically. Outside a feed this degrades to plain `useState`, so a
521
+ * widget can still be rendered on its own.
522
+ */
523
+ var useWidgetState = (key, initial) => {
524
+ const store = useContext(WidgetStateContext);
525
+ const scope = useContext(WidgetScopeContext);
526
+ const scoped = useMemo(() => `${scope}:${key}`, [scope, key]);
527
+ const [value, setValue] = useState(() => {
528
+ const stored = store?.read(scoped);
529
+ return stored === void 0 ? initial : stored;
530
+ });
531
+ return [value, useCallback((next) => {
532
+ store?.write(scoped, next);
533
+ setValue(next);
534
+ }, [store, scoped])];
535
+ };
536
+ //#endregion
537
+ //#region src/components/Block/HtmlBlock.tsx
538
+ /**
539
+ * A message rendered as sanitized HTML — the email case, which is not markdown and cannot be a
540
+ * CodeMirror document.
541
+ *
542
+ * Its presence is the reason the item renderer is generalized rather than markdown-only: the
543
+ * engine owns virtualization, chrome and selection anchors, not what a message looks like inside.
544
+ * As plain (non-contenteditable) DOM it is also the control case for cross-item selection.
545
+ */
546
+ var HtmlBlock = memo(({ html }) => {
547
+ const sanitized = useMemo(() => DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }), [html]);
548
+ return /* @__PURE__ */ jsx("div", {
549
+ className: "dx-feed-html",
550
+ dangerouslySetInnerHTML: { __html: sanitized }
551
+ });
552
+ });
553
+ HtmlBlock.displayName = "HtmlBlock";
554
+ //#endregion
555
+ //#region src/components/Block/extensions.ts
556
+ /**
557
+ * The extension set one item's document is built from.
558
+ *
559
+ * Everything that does not depend on the item is **built once and shared**, and that is the point of
560
+ * this module rather than a tidiness. `EditorView.theme()` mints a new `StyleModule` and a new
561
+ * generated class on every call, so a set built per item injects one stylesheet per row and
562
+ * invalidates the whole document's style each time it does. Measured at ~14ms per row against ~2ms
563
+ * for the identical set shared (`baseline/mount`) — six times the cost of the first fill, for
564
+ * stylesheets that are all the same.
565
+ *
566
+ * Extensions are descriptors, not instances: a `StateField` or `ViewPlugin` in a shared array is
567
+ * still instantiated per view, so sharing changes nothing but the identity CodeMirror dedupes on.
568
+ */
569
+ var createBlockExtensions = ({ registry, editable = false, themeMode = "light", setWidgets } = {}) => [...sharedExtensions(registry, editable, themeMode), ...registry ? [xmlTags({
570
+ registry,
571
+ setWidgets: setWidgets ?? (() => {}),
572
+ bookmarks: ["prompt"]
573
+ })] : []];
574
+ /** Registries are compared by identity, so a feed's single registry is a single cache scope. */
575
+ var NO_REGISTRY = {};
576
+ var cache = /* @__PURE__ */ new WeakMap();
577
+ var sharedExtensions = (registry, editable, themeMode) => {
578
+ const scope = registry ?? NO_REGISTRY;
579
+ let byVariant = cache.get(scope);
580
+ if (!byVariant) {
581
+ byVariant = /* @__PURE__ */ new Map();
582
+ cache.set(scope, byVariant);
583
+ }
584
+ const key = `${editable}:${themeMode}`;
585
+ let extensions = byVariant.get(key);
586
+ if (!extensions) {
587
+ extensions = build(registry, editable, themeMode);
588
+ byVariant.set(key, extensions);
589
+ }
590
+ return extensions;
591
+ };
592
+ var build = (registry, editable, themeMode) => [
593
+ createBasicExtensions({
594
+ readOnly: !editable,
595
+ editable,
596
+ lineWrapping: true
597
+ }),
598
+ createThemeExtensions({ themeMode }),
599
+ registry ? extendedMarkdown({ registry }) : createMarkdownExtensions(),
600
+ registry && xmlFormatting({ skip: ["prompt"] }),
601
+ decorateMarkdown(),
602
+ registry?.prompt && xmlBlockDecoration({
603
+ tag: "prompt",
604
+ hideTags: true
605
+ }),
606
+ highlights,
607
+ highlightTheme
608
+ ].filter(Boolean);
609
+ //#endregion
610
+ //#region src/components/Block/MarkdownBlock.tsx
611
+ /**
612
+ * One message as its own markdown document.
613
+ *
614
+ * Each item owns a document, so streaming appends and per-message editing stay local — where a
615
+ * single thread-wide document needs a cursor and a range table to know which message it is touching.
616
+ */
617
+ var MarkdownBlock = memo(({ text, editable = false, registry, hits, onWidgetsChange }) => {
618
+ const { themeMode } = useThemeContext();
619
+ const [view, setView] = useState(null);
620
+ const [widgets, setWidgets] = useState([]);
621
+ const rootRef = useRef(null);
622
+ const selectionGroup = useSelectionGroup();
623
+ const selectionGroupRef = useRef(selectionGroup);
624
+ selectionGroupRef.current = selectionGroup;
625
+ const extensions = useMemo(() => [...createBlockExtensions({
626
+ registry,
627
+ editable,
628
+ themeMode,
629
+ setWidgets
630
+ }), EditorView.updateListener.of((update) => {
631
+ if (update.selectionSet && !update.state.selection.main.empty) selectionGroupRef.current.claim(update.view);
632
+ })], [
633
+ editable,
634
+ themeMode,
635
+ registry
636
+ ]);
637
+ const initialTextRef = useRef(text);
638
+ useLayoutEffect(() => {
639
+ const parent = rootRef.current;
640
+ if (!parent) return;
641
+ const instance = new EditorView({
642
+ parent,
643
+ state: EditorState.create({
644
+ doc: initialTextRef.current,
645
+ extensions
646
+ })
647
+ });
648
+ const unregister = selectionGroupRef.current.register(instance);
649
+ setView(instance);
650
+ return () => {
651
+ unregister();
652
+ instance.destroy();
653
+ setView(null);
654
+ };
655
+ }, [extensions]);
656
+ const currentRef = useRef(text);
657
+ useEffect(() => {
658
+ if (!view || currentRef.current === text) return;
659
+ const previous = currentRef.current;
660
+ currentRef.current = text;
661
+ initialTextRef.current = text;
662
+ view.dispatch(text.startsWith(previous) ? { changes: {
663
+ from: previous.length,
664
+ insert: text.slice(previous.length)
665
+ } } : { changes: {
666
+ from: 0,
667
+ to: view.state.doc.length,
668
+ insert: text
669
+ } });
670
+ }, [view, text]);
671
+ const highlighted = useRef(false);
672
+ useEffect(() => {
673
+ if (!view || !hits?.length && !highlighted.current) return;
674
+ highlighted.current = Boolean(hits?.length);
675
+ view.dispatch({ effects: setHighlights.of(hits ?? []) });
676
+ }, [view, hits]);
677
+ useEffect(() => {
678
+ onWidgetsChange?.(widgets.length);
679
+ return () => onWidgetsChange?.(0);
680
+ }, [widgets.length, onWidgetsChange]);
681
+ useEffect(() => {
682
+ if (!view || !widgets.length) return;
683
+ const observer = new ResizeObserver(() => view.requestMeasure());
684
+ for (const { root } of widgets) observer.observe(root);
685
+ return () => observer.disconnect();
686
+ }, [view, widgets]);
687
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", { ref: rootRef }), widgets.map(({ Component, root, id, props }) => /* @__PURE__ */ jsx("div", { children: createPortal(/* @__PURE__ */ jsx(Component, {
688
+ view: view ?? void 0,
689
+ ...props
690
+ }), root) }, id))] });
691
+ });
692
+ MarkdownBlock.displayName = "MarkdownBlock";
693
+ //#endregion
694
+ //#region src/model/feed-model.ts
695
+ /**
696
+ * Default renderer: an `text/html` block wins (email), otherwise every block is flattened to
697
+ * markdown. Block kinds the engine does not know become fenced JSON so nothing renders as blank.
698
+ */
699
+ var defaultRenderer = (message) => createContent(message, false);
700
+ /**
701
+ * Assistant chat: as {@link defaultRenderer}, plus the reader's own turns marked with `<prompt>`.
702
+ *
703
+ * Separate because the tag is only meaningful where a widget registry knows it — a feed rendered
704
+ * without one would show the reader their own angle brackets, which is what a human chat, a
705
+ * transcript and a comment list all are.
706
+ */
707
+ var chatRenderer = (message) => createContent(message, true);
708
+ var createContent = (message, prompts) => {
709
+ const html = message.blocks.filter((block) => block._tag === "text").find((block) => block.mimeType === "text/html");
710
+ if (html) return {
711
+ kind: "html",
712
+ html: html.text
713
+ };
714
+ return {
715
+ kind: "markdown",
716
+ text: message.blocks.map((block) => blockToMarkdown(message, block, prompts)).filter(Boolean).join("\n\n")
717
+ };
718
+ };
719
+ /**
720
+ * One block as markdown, with anything that is not prose emitted as an XML tag for a widget to
721
+ * render. A message is the concatenation of these, which is what makes an item a document rather
722
+ * than a component tree: the blocks are content, and the chrome around them belongs to the host.
723
+ */
724
+ var blockToMarkdown = (message, block, prompts) => {
725
+ switch (block._tag) {
726
+ case "text": return prompts && message.sender.role === "user" ? tag("prompt", block.text, block) : block.text.trim();
727
+ case "reasoning": return tag("reasoning", block.reasoningText ?? block.redactedText ?? "", block);
728
+ case "status": return tag("status", block.statusText, block);
729
+ case "summary": return tag("summary", block.content, block);
730
+ case "suggestion": return tag("suggestion", block.text, block);
731
+ case "select": return block.options.length ? `<select>${block.options.map((option) => `<option>${escapeXml(option)}</option>`).join("")}</select>` : "";
732
+ case "toolCall": return `<toolCall id="${escapeXml(block.toolCallId)}" name="${escapeXml(block.name)}"${block.pending ? " pending=\"true\"" : ""} />`;
733
+ case "toolResult": return tag("toolResult", String(block.result ?? block.error ?? ""), block, `for="${escapeXml(block.name)}"`);
734
+ default: return tag("json", JSON.stringify(block), block);
735
+ }
736
+ };
737
+ /**
738
+ * An XML tag holding block content.
739
+ *
740
+ * A pending block is emitted **unclosed**, which is the streaming contract: the item reconciles by
741
+ * appending, so a closing tag written before the content is complete would have to be rewritten on
742
+ * every chunk — and rewriting the document is what discards decorations and scroll position.
743
+ *
744
+ * Paragraph breaks inside the content are collapsed, since a blank line would end the enclosing
745
+ * markdown block and leave the rest of the tag parsing as prose.
746
+ */
747
+ var tag = (name, content, block, attributes) => {
748
+ const text = escapeXml(content.replace(/\n\n+/g, " ").trim());
749
+ if (!text.length && !block.pending) return "";
750
+ const open = `<${name}${attributes ? ` ${attributes}` : ""}>`;
751
+ return block.pending ? `${open}${text}` : `${open}${text}</${name}>`;
752
+ };
753
+ var escapeXml = (raw) => raw.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
754
+ /** Plain text of a message, used by search and copy so both read the same projection. */
755
+ var messageText = (message, renderer) => {
756
+ const content = renderer(message);
757
+ switch (content.kind) {
758
+ case "markdown": return content.text;
759
+ case "html": return stripHtml(content.html);
760
+ case "custom": return "";
761
+ }
762
+ };
763
+ /** Crude tag strip — the engine only needs searchable/copyable text, not a parse tree. */
764
+ var stripHtml = (html) => html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
765
+ /**
766
+ * Search the model rather than the DOM.
767
+ *
768
+ * Virtualization means most messages are not mounted, and a `viewType` filter can hide blocks that
769
+ * are still in the model — neither is reachable by a per-view CodeMirror search, so thread-wide find
770
+ * has to run over the projection.
771
+ */
772
+ var searchFeed = (messages, renderer, query) => {
773
+ const hits = [];
774
+ if (!query.length) return hits;
775
+ const needle = query.toLowerCase();
776
+ messages.forEach((message, index) => {
777
+ const text = messageText(message, renderer).toLowerCase();
778
+ let offset = text.indexOf(needle);
779
+ while (offset !== -1) {
780
+ hits.push({
781
+ messageId: message.id,
782
+ index,
783
+ offset,
784
+ length: query.length
785
+ });
786
+ offset = text.indexOf(needle, offset + needle.length);
787
+ }
788
+ });
789
+ return hits;
790
+ };
791
+ /**
792
+ * Text between two anchors, spanning message boundaries.
793
+ *
794
+ * Native selection cannot span separate editor instances, and unmounted messages are not in the DOM
795
+ * at all, so cross-message copy is reconstructed from the model.
796
+ */
797
+ var sliceFeed = (messages, renderer, range) => {
798
+ const fromIndex = messages.findIndex((message) => message.id === range.from.messageId);
799
+ const toIndex = messages.findIndex((message) => message.id === range.to.messageId);
800
+ if (fromIndex === -1 || toIndex === -1) return "";
801
+ const [start, end] = fromIndex < toIndex || fromIndex === toIndex && range.from.offset <= range.to.offset ? [range.from, range.to] : [range.to, range.from];
802
+ const [startIndex, endIndex] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
803
+ const parts = [];
804
+ for (let index = startIndex; index <= endIndex; index++) {
805
+ const text = messageText(messages[index], renderer);
806
+ const from = index === startIndex ? start.offset : 0;
807
+ const to = index === endIndex ? end.offset : text.length;
808
+ parts.push(text.slice(from, to));
809
+ }
810
+ return parts.join("\n\n");
811
+ };
812
+ //#endregion
813
+ //#region src/model/list-model.ts
814
+ /**
815
+ * The low-level reactive collection the virtualizer binds to (SPEC: ListModel).
816
+ *
817
+ * Two faces on one state, deliberately: a plain synchronous face (`count`, `at`, `subscribe`) that
818
+ * the virtualizer consumes without dragging an atom registry into every story and test, and an
819
+ * atom face (`rowsAtom`) for hosts that live in atom-land — derivations (stops, decorations,
820
+ * readouts) hang off the atom rather than becoming parallel props. Both are the same array; the
821
+ * subscribe face is not a fallback but the binding's contract.
822
+ */
823
+ var ListModel = class {
824
+ #items;
825
+ #getId;
826
+ #listeners = /* @__PURE__ */ new Set();
827
+ /** The rows as an atom, for derivations. Registry-managed by whoever consumes it. */
828
+ rowsAtom;
829
+ #registry;
830
+ constructor({ items = [], getId }) {
831
+ this.#items = [...items];
832
+ this.#getId = getId;
833
+ this.rowsAtom = Atom.make(this.#items);
834
+ this.#registry = Registry.make();
835
+ }
836
+ get count() {
837
+ return this.#items.length;
838
+ }
839
+ get items() {
840
+ return this.#items;
841
+ }
842
+ getId = (index) => {
843
+ const item = this.#items[index];
844
+ return item === void 0 ? `missing-${index}` : this.#getId(item);
845
+ };
846
+ at = (index) => this.#items[index];
847
+ indexOf(id) {
848
+ return this.#items.findIndex((item) => this.#getId(item) === id);
849
+ }
850
+ subscribe(listener) {
851
+ this.#listeners.add(listener);
852
+ return () => this.#listeners.delete(listener);
853
+ }
854
+ append(items) {
855
+ if (!items.length) return;
856
+ this.#items = [...this.#items, ...items];
857
+ this.#publish({ appended: items.length });
858
+ }
859
+ prepend(items) {
860
+ if (!items.length) return;
861
+ this.#items = [...items, ...this.#items];
862
+ this.#publish({ prepended: items.length });
863
+ }
864
+ /** An item changed in place — same identity, new content. */
865
+ update(id) {
866
+ this.#publish({ updated: [id] });
867
+ }
868
+ /**
869
+ * Replace one item with a successor of the same identity — the streaming case for *frozen*
870
+ * items. An ECHO object mutates and `update` announces it; a schema-made message is frozen, so
871
+ * the chunk arrives as a fresh value under the old id. Same event either way: `updated`.
872
+ */
873
+ patch(id, next) {
874
+ const index = this.#items.findIndex((item) => this.#getId(item) === id);
875
+ if (index < 0 || this.#getId(next) !== id) return;
876
+ this.#items = [
877
+ ...this.#items.slice(0, index),
878
+ next,
879
+ ...this.#items.slice(index + 1)
880
+ ];
881
+ this.#publish({ updated: [id] });
882
+ }
883
+ /**
884
+ * The adapter for hosts that hand over a whole new array (a React prop, a query result).
885
+ *
886
+ * The one place the prepend inference survives, because this is the one caller that genuinely
887
+ * does not know what changed: the old first item is found in the new array, and everything before
888
+ * it was prepended. The virtualizer itself is always told (SPEC F-7.1).
889
+ */
890
+ replace(items) {
891
+ const previous = this.#items;
892
+ const previousFirst = previous.length ? this.#getId(previous[0]) : void 0;
893
+ this.#items = [...items];
894
+ let prepended = 0;
895
+ if (previousFirst !== void 0 && items.length > previous.length) {
896
+ const grew = items.length - previous.length;
897
+ for (let index = 1; index <= grew; index++) if (items[index] !== void 0 && this.#getId(items[index]) === previousFirst) {
898
+ prepended = index;
899
+ break;
900
+ }
901
+ }
902
+ const appended = prepended ? 0 : Math.max(0, items.length - previous.length);
903
+ this.#publish({
904
+ prepended: prepended || void 0,
905
+ appended: appended || void 0
906
+ });
907
+ }
908
+ #publish(change) {
909
+ this.#registry.set(this.rowsAtom, this.#items);
910
+ for (const listener of this.#listeners) listener(change);
911
+ }
912
+ };
913
+ //#endregion
914
+ //#region src/model/feed.ts
915
+ /**
916
+ * The native case, not an add-on (SPEC: FeedModel): a ListModel of messages carrying the feed's
917
+ * own semantics — the stops policy, the streaming tail, and iteration. The list consumes this and
918
+ * nothing else; `hits`, `isAnchor` and `streamingId` all left the props API and live here or in
919
+ * the decoration provider.
920
+ *
921
+ * ECHO/queue bindings adapt to this outside the package; `fromMessages` adapts the plain case.
922
+ */
923
+ var FeedModel = class extends ListModel {
924
+ #stops;
925
+ #streamingId;
926
+ #loading = false;
927
+ #loadBefore;
928
+ #loadAfter;
929
+ constructor({ messages = [], stops = "message", loadBefore, loadAfter } = {}) {
930
+ super({
931
+ items: messages,
932
+ getId: (message) => message.id
933
+ });
934
+ this.#stops = stops;
935
+ this.#loadBefore = loadBefore;
936
+ this.#loadAfter = loadAfter;
937
+ }
938
+ get messages() {
939
+ return this.items;
940
+ }
941
+ /** The tail message reconciling by delta; its item must not remount per chunk. */
942
+ get streamingId() {
943
+ return this.#streamingId;
944
+ }
945
+ setStreaming(id) {
946
+ const previous = this.#streamingId;
947
+ this.#streamingId = id;
948
+ for (const changed of [previous, id]) if (changed) this.update(changed);
949
+ }
950
+ /** A chunk arrived for the streaming tail: same identity, longer content. */
951
+ stream(id) {
952
+ this.update(id);
953
+ }
954
+ setStops(stops) {
955
+ this.#stops = stops;
956
+ }
957
+ /**
958
+ * The positions navigation can land on, under the current policy.
959
+ *
960
+ * Recomputed on demand rather than cached: a policy is a predicate over the model, and the model
961
+ * knows when it changed — callers that want reactivity derive from `rowsAtom`.
962
+ */
963
+ stops() {
964
+ const predicate = this.#stops === "message" ? () => true : this.#stops === "prompt" ? (message) => message.sender.role === "user" : this.#stops;
965
+ const stops = [];
966
+ this.messages.forEach((message, index) => {
967
+ if (predicate(message, index)) stops.push({
968
+ index,
969
+ id: message.id
970
+ });
971
+ });
972
+ return stops;
973
+ }
974
+ /**
975
+ * The window reached the start: ask the source for history.
976
+ *
977
+ * The model owns iteration (SPEC F-7.2) — the pages arrive as a prepend, which the virtualizer
978
+ * is told about, so nothing on screen moves (F-1.1). Serialized, because edges are reached once
979
+ * per frame while the reader sits on them.
980
+ */
981
+ async more(edge) {
982
+ const load = edge === "start" ? this.#loadBefore : this.#loadAfter;
983
+ if (!load || this.#loading) return false;
984
+ this.#loading = true;
985
+ try {
986
+ const page = await load();
987
+ if (!page.length) return false;
988
+ if (edge === "start") this.prepend(page);
989
+ else this.append(page);
990
+ return true;
991
+ } finally {
992
+ this.#loading = false;
993
+ }
994
+ }
995
+ /** Model-level search, since virtualization hides most items from any DOM search. */
996
+ search(query, renderer = defaultRenderer) {
997
+ return searchFeed(this.messages, renderer, query);
998
+ }
999
+ /** The rendered text of one message, for previews and rails. */
1000
+ textOf(id, renderer = defaultRenderer) {
1001
+ const index = this.indexOf(id);
1002
+ const message = this.at(index);
1003
+ return message && messageText(message, renderer);
1004
+ }
1005
+ };
1006
+ /** The plain case: a feed over an array the host already has. */
1007
+ var fromMessages = (messages, options = {}) => new FeedModel({
1008
+ messages,
1009
+ ...options
1010
+ });
1011
+ //#endregion
1012
+ //#region src/model/useListModel.ts
1013
+ /**
1014
+ * A model over an array the host re-creates per render — the React-prop case.
1015
+ *
1016
+ * The model is stable; the array is folded into it with `replace`, which is where the prepend
1017
+ * inference lives (and the only place — SPEC F-7.1). In an effect rather than during render: the
1018
+ * model publishes synchronously to its subscribers, and a subscriber invalidating itself while the
1019
+ * host is still rendering is a cross-component update React rejects. The window it opens is one
1020
+ * frame, and the window's own subscription closes it.
1021
+ */
1022
+ var useListModel = (items, getId) => {
1023
+ const model = useMemo(() => new ListModel({
1024
+ items,
1025
+ getId
1026
+ }), []);
1027
+ useEffect(() => {
1028
+ if (model.items !== items) model.replace(items);
1029
+ }, [model, items]);
1030
+ return model;
1031
+ };
1032
+ /**
1033
+ * The feed-shaped twin, for a host that re-creates its message array per render.
1034
+ *
1035
+ * Streaming through this adapter is a `replace` whose length does not change; the model still
1036
+ * publishes, so the window still re-renders, and the streaming item reconciles by document delta
1037
+ * exactly as it would under a told update.
1038
+ */
1039
+ var useFeedModel = (messages, options = {}) => {
1040
+ const model = useMemo(() => new FeedModel({
1041
+ messages,
1042
+ ...options
1043
+ }), []);
1044
+ useEffect(() => {
1045
+ if (model.messages !== messages) model.replace(messages);
1046
+ }, [model, messages]);
1047
+ return model;
1048
+ };
1049
+ //#endregion
1050
+ //#region src/virtualizer/placement.ts
1051
+ var DEFAULT_OVERSCAN = 8;
1052
+ /**
1053
+ *
1054
+ */
1055
+ var Placement = class {
1056
+ #count;
1057
+ #getId;
1058
+ #extents;
1059
+ #viewport;
1060
+ #overscan;
1061
+ #reserve;
1062
+ /** Measured extents by message id, so that reordering the model cannot invalidate them. */
1063
+ #measured = /* @__PURE__ */ new Map();
1064
+ /** The one authoritative position: this row starts exactly here. */
1065
+ #anchor;
1066
+ #scroll = 0;
1067
+ constructor({ count, getId, extents, viewport, overscan = DEFAULT_OVERSCAN, reserve = 0 }) {
1068
+ this.#count = count;
1069
+ this.#getId = getId;
1070
+ this.#extents = extents;
1071
+ this.#viewport = viewport;
1072
+ this.#overscan = overscan;
1073
+ this.#reserve = reserve;
1074
+ this.#anchor = {
1075
+ id: count ? getId(0) : "",
1076
+ index: 0,
1077
+ start: 0
1078
+ };
1079
+ }
1080
+ get scroll() {
1081
+ return this.#scroll;
1082
+ }
1083
+ get count() {
1084
+ return this.#count;
1085
+ }
1086
+ /** The viewport is the binding's to know: it is the one thing here that comes from the DOM. */
1087
+ setViewport(viewport) {
1088
+ this.#viewport = viewport;
1089
+ }
1090
+ /**
1091
+ * The host's answer about extents can change — a row is edited, a panel opens, a story flips a
1092
+ * fixture — and it is a *function*, so keeping the one handed over at construction quietly pins
1093
+ * the layout to whatever was true then. Measured extents survive: they are keyed by id and this
1094
+ * only replaces what the host would say about a row nobody has measured.
1095
+ */
1096
+ setExtents(extents) {
1097
+ this.#extents = extents;
1098
+ }
1099
+ setReserve(reserve) {
1100
+ this.#reserve = reserve;
1101
+ }
1102
+ /**
1103
+ * The host's identity function, which is a closure over the host's own list.
1104
+ *
1105
+ * Appending a message gives a *new* `getId`, and a placement holding the one it was constructed
1106
+ * with resolves the new row's index to an id nobody will ever measure. The binding then stores
1107
+ * that row's measurement under the id the DOM carries and reads it back under the stale one, so
1108
+ * the row is measured, found to disagree, measured again — every commit, for ever. It presents as
1109
+ * a render loop rather than as a wrong size, which is what made `bridge/Tail` look like a tail
1110
+ * defect (§8: measurement is only a correction if it can be read back).
1111
+ */
1112
+ setGetId(getId) {
1113
+ this.#getId = getId;
1114
+ }
1115
+ get anchor() {
1116
+ return this.#anchor;
1117
+ }
1118
+ /** Extent of one row: what was measured, or what the host says. */
1119
+ extentOf(index) {
1120
+ return this.#measured.get(this.#getId(index)) ?? this.#extents.of(index);
1121
+ }
1122
+ /**
1123
+ * Absolute position of a row, derived from the anchor rather than summed from the start.
1124
+ *
1125
+ * Rows between here and the anchor are the only ones whose extents matter (§5): everything before
1126
+ * the window is described by the anchor's position, not by adding up guesses about it.
1127
+ */
1128
+ positionOf(index) {
1129
+ let position = this.#anchor.start;
1130
+ if (index >= this.#anchor.index) for (let row = this.#anchor.index; row < index; row++) position += this.extentOf(row);
1131
+ else for (let row = index; row < this.#anchor.index; row++) position -= this.extentOf(row);
1132
+ return position;
1133
+ }
1134
+ /** The reader moved. Nothing is corrected here — only the mounted range changes. */
1135
+ scrollTo(offset) {
1136
+ this.#scroll = offset;
1137
+ this.#reanchor();
1138
+ }
1139
+ /**
1140
+ * A row turned out to be a different size than assumed.
1141
+ *
1142
+ * Rows at or after the anchor push the ones after them and leave the anchor alone. A row *before*
1143
+ * the anchor moves the rows between it and the anchor, which is correct and unavoidable: the
1144
+ * anchor is the thing being held still, and it is still being held.
1145
+ */
1146
+ measure(id, extent) {
1147
+ if (this.#extents.exact) return;
1148
+ this.#measured.set(id, extent);
1149
+ }
1150
+ /** The model changed length. `prepended` rows arrived before the anchor and shift its index. */
1151
+ setCount(count, { prepended = 0 } = {}) {
1152
+ this.#count = count;
1153
+ if (prepended) this.#anchor = {
1154
+ ...this.#anchor,
1155
+ index: this.#anchor.index + prepended
1156
+ };
1157
+ }
1158
+ /**
1159
+ * Move to a row that may be nowhere near the window.
1160
+ *
1161
+ * The only operation that positions from estimates rather than from measurement, because there is
1162
+ * no measured path to somewhere the reader has never been. A jump is a discontinuity the reader
1163
+ * asked for, so being approximately right is what it can be.
1164
+ */
1165
+ jumpTo(index, align = "start") {
1166
+ const clamped = Math.max(0, Math.min(index, this.#count - 1));
1167
+ let start = 0;
1168
+ for (let row = 0; row < clamped; row++) start += this.extentOf(row);
1169
+ this.#anchor = {
1170
+ id: this.#getId(clamped),
1171
+ index: clamped,
1172
+ start
1173
+ };
1174
+ const end = clamped === this.#count - 1 ? this.layout().sizerExtent - this.#reserve : start + this.extentOf(clamped);
1175
+ this.#scroll = align === "end" ? end - this.#viewport : start;
1176
+ this.#scroll = Math.max(0, this.#scroll);
1177
+ this.#reanchor();
1178
+ }
1179
+ /**
1180
+ * Where the reader has to be for `index` to sit at the start (or end) of the viewport.
1181
+ *
1182
+ * Reports; does not move. `jumpTo` re-anchors as well, which is right for a discontinuity and
1183
+ * wrong for a glide: the anchor would arrive at the destination while the element was still
1184
+ * travelling, so the reader would watch the rows they were scrolling *through* unmount and the
1185
+ * journey happen over nothing.
1186
+ */
1187
+ offsetOf(index, align = "start") {
1188
+ const clamped = Math.max(0, Math.min(index, this.#count - 1));
1189
+ const start = this.positionOf(clamped);
1190
+ return Math.max(0, align === "end" ? start + this.extentOf(clamped) - this.#viewport : start);
1191
+ }
1192
+ /**
1193
+ * Where the reader has to be for the last row to rest against the end of the viewport.
1194
+ *
1195
+ * Derived from the anchor, like every other position, and deliberately **not** from a sum over the
1196
+ * model: a follow asks this again on every commit, and each answer mounts rows whose measurement
1197
+ * replaces the estimates that sum is over — so a sum-based end moves every time it is consulted,
1198
+ * and consulting it is what moves it. That is not slow convergence, it is a feedback loop, and in
1199
+ * React it terminates by exceeding the update limit rather than by settling (`bridge/Tail`).
1200
+ *
1201
+ * Anchor-relative, the answer only changes as the rows *between the anchor and the last row* are
1202
+ * measured — each of them once — so a follow settles in a couple of commits and rows the reader
1203
+ * has already passed cannot disturb it.
1204
+ */
1205
+ endOffset() {
1206
+ if (!this.#count) return 0;
1207
+ const last = this.#count - 1;
1208
+ return Math.max(0, this.positionOf(last) + this.extentOf(last) - this.#viewport);
1209
+ }
1210
+ /** The mounted range, the parent's offset, and the extent the thumb is computed from. */
1211
+ layout() {
1212
+ if (!this.#count) return {
1213
+ first: 0,
1214
+ last: -1,
1215
+ visible: {
1216
+ first: 0,
1217
+ last: -1
1218
+ },
1219
+ offset: 0,
1220
+ sizerExtent: 0
1221
+ };
1222
+ const { first, last, visible } = this.#range();
1223
+ const offset = this.positionOf(first);
1224
+ let windowExtent = 0;
1225
+ for (let row = first; row <= last; row++) windowExtent += this.extentOf(row);
1226
+ let after = 0;
1227
+ for (let row = last + 1; row < this.#count; row++) after += this.extentOf(row);
1228
+ return {
1229
+ first,
1230
+ last,
1231
+ visible,
1232
+ offset,
1233
+ sizerExtent: offset + windowExtent + after + this.#reserve
1234
+ };
1235
+ }
1236
+ /**
1237
+ * Repay the start edge's drift: shift every position so row 0 starts at exactly zero, and move
1238
+ * the scroll by the same amount so nothing the reader sees moves.
1239
+ *
1240
+ * The first row starting at zero is the one absolute fact of content space, and estimates being
1241
+ * replaced above the anchor break it: a feed opened at its tail whose rows measure taller than
1242
+ * assumed pushes row 0 *negative*, where no scroll can reach it — the reader arrives at the top
1243
+ * and the first messages simply are not there. This is the single correction that must touch the
1244
+ * scroll (§7's rule is about corrections and readers sharing a channel; here both the content and
1245
+ * the offset move by the same delta in the same commit, so the reader sees nothing at all).
1246
+ * Returns the shift for the binding to apply to the element.
1247
+ */
1248
+ rebaseStart() {
1249
+ if (!this.#count) return 0;
1250
+ const shift = -this.positionOf(0);
1251
+ if (shift === 0) return 0;
1252
+ this.#anchor = {
1253
+ ...this.#anchor,
1254
+ start: this.#anchor.start + shift
1255
+ };
1256
+ this.#scroll = Math.max(0, this.#scroll + shift);
1257
+ return shift;
1258
+ }
1259
+ /**
1260
+ * What an edge revealed, if the window has reached one.
1261
+ *
1262
+ * The edges are where estimate meets ground truth: the first row starts at zero and the last one
1263
+ * ends at the content's end, by definition, so a computed position that says otherwise is the
1264
+ * measure of how wrong the estimates were. Reported rather than silently applied — absorbing it
1265
+ * into the anchor would move what the reader is looking at, and at an edge they are looking at
1266
+ * exactly the thing that would move.
1267
+ */
1268
+ drift() {
1269
+ if (!this.#count) return;
1270
+ const { first, last } = this.#range();
1271
+ if (first === 0 && this.positionOf(0) !== 0) return {
1272
+ edge: "start",
1273
+ delta: this.positionOf(0)
1274
+ };
1275
+ if (last === this.#count - 1) {
1276
+ const end = this.positionOf(last) + this.extentOf(last);
1277
+ const { sizerExtent } = this.layout();
1278
+ const content = sizerExtent - this.#reserve;
1279
+ if (end !== content) return {
1280
+ edge: "end",
1281
+ delta: content - end
1282
+ };
1283
+ }
1284
+ }
1285
+ /** Visible rows, plus overscan, clamped to the model. */
1286
+ #range() {
1287
+ let first = this.#anchor.index;
1288
+ while (first > 0 && this.positionOf(first) > this.#scroll) first--;
1289
+ while (first < this.#count - 1 && this.positionOf(first) + this.extentOf(first) <= this.#scroll) first++;
1290
+ let last = first;
1291
+ let filled = this.positionOf(first) + this.extentOf(first);
1292
+ while (last < this.#count - 1 && filled < this.#scroll + this.#viewport) {
1293
+ last++;
1294
+ filled += this.extentOf(last);
1295
+ }
1296
+ return {
1297
+ first: Math.max(0, first - this.#overscan),
1298
+ last: Math.min(this.#count - 1, last + this.#overscan),
1299
+ visible: {
1300
+ first,
1301
+ last
1302
+ }
1303
+ };
1304
+ }
1305
+ /**
1306
+ * The anchor is the first row the reader can see — always, not merely when the old one leaves
1307
+ * the window.
1308
+ *
1309
+ * The anchor is the row held still while others are measured, so it must be the reader's
1310
+ * reference point. Clamping the old anchor into the mounted range let it drift to the window's
1311
+ * *far* edge after an upward scroll — below everything on screen — and a widget toggled open
1312
+ * above it then pushed the whole viewport up by its own growth: the engine held a row the reader
1313
+ * could not see still, at the expense of every row they could. Re-anchoring preserves the new
1314
+ * anchor's computed position exactly, so the move itself shifts nothing.
1315
+ */
1316
+ #reanchor() {
1317
+ if (!this.#count) return;
1318
+ const { visible } = this.#range();
1319
+ if (this.#anchor.index === visible.first) return;
1320
+ const index = visible.first;
1321
+ this.#anchor = {
1322
+ id: this.#getId(index),
1323
+ index,
1324
+ start: this.positionOf(index)
1325
+ };
1326
+ }
1327
+ };
1328
+ //#endregion
1329
+ //#region src/virtualizer/Window.tsx
1330
+ /** Extent of the mounted rows, which is the parent's own size in content space. */
1331
+ var windowExtentOf = (placement, first, last) => {
1332
+ let extent = 0;
1333
+ for (let index = first; index <= last; index++) extent += placement.extentOf(index);
1334
+ return extent;
1335
+ };
1336
+ var useWindow = ({ scrollerRef, model, extents, axis = "block", overscan, reserve = 0, onEdge, onMismatch, onChange, controllerRef }) => {
1337
+ const windowRef = useRef(null);
1338
+ const [, render] = useState(0);
1339
+ const invalidate = useCallback(() => render((value) => value + 1), []);
1340
+ const count = model.count;
1341
+ const getId = model.getId;
1342
+ const placement = useMemo(() => new Placement({
1343
+ count,
1344
+ getId,
1345
+ extents,
1346
+ viewport: 0,
1347
+ overscan
1348
+ }), []);
1349
+ const reported = useRef(/* @__PURE__ */ new Set());
1350
+ placement.setGetId(getId);
1351
+ useEffect(() => {
1352
+ const unsubscribe = model.subscribe((change) => {
1353
+ placement.setCount(model.count, { prepended: change.prepended ?? 0 });
1354
+ invalidate();
1355
+ });
1356
+ placement.setCount(model.count, {});
1357
+ invalidate();
1358
+ return unsubscribe;
1359
+ }, [
1360
+ model,
1361
+ placement,
1362
+ invalidate
1363
+ ]);
1364
+ if (placement.count !== model.count) placement.setCount(model.count, {});
1365
+ useImperativeHandle(controllerRef, () => ({ scrollToIndex: (index, align, behavior = "auto") => {
1366
+ const scroller = scrollerRef.current;
1367
+ const { first, last } = placement.layout();
1368
+ if (behavior === "smooth" && scroller && index >= first && index <= last) {
1369
+ const target = placement.offsetOf(index, align);
1370
+ scroller.scrollTo(axis === "block" ? {
1371
+ top: target,
1372
+ behavior
1373
+ } : {
1374
+ left: target,
1375
+ behavior
1376
+ });
1377
+ return;
1378
+ }
1379
+ placement.jumpTo(index, align);
1380
+ if (scroller) {
1381
+ if (axis === "block") scroller.scrollTop = placement.scroll;
1382
+ else scroller.scrollLeft = placement.scroll;
1383
+ }
1384
+ invalidate();
1385
+ } }), [
1386
+ placement,
1387
+ axis,
1388
+ invalidate
1389
+ ]);
1390
+ placement.setExtents(extents);
1391
+ placement.setReserve(reserve);
1392
+ const { first, last, visible, offset, sizerExtent } = placement.layout();
1393
+ useLayoutEffect(() => {
1394
+ const scroller = scrollerRef.current;
1395
+ if (!scroller) return;
1396
+ const read = () => axis === "block" ? scroller.clientHeight : scroller.clientWidth;
1397
+ placement.setViewport(read());
1398
+ const observer = new ResizeObserver(() => {
1399
+ placement.setViewport(read());
1400
+ invalidate();
1401
+ });
1402
+ observer.observe(scroller);
1403
+ const onScroll = () => {
1404
+ placement.scrollTo(axis === "block" ? scroller.scrollTop : scroller.scrollLeft);
1405
+ invalidate();
1406
+ };
1407
+ scroller.addEventListener("scroll", onScroll, { passive: true });
1408
+ invalidate();
1409
+ return () => {
1410
+ observer.disconnect();
1411
+ scroller.removeEventListener("scroll", onScroll);
1412
+ };
1413
+ }, [
1414
+ placement,
1415
+ axis,
1416
+ invalidate
1417
+ ]);
1418
+ useLayoutEffect(() => {
1419
+ const parent = windowRef.current;
1420
+ if (!parent) return;
1421
+ let changed = false;
1422
+ for (const element of parent.children) {
1423
+ const row = element;
1424
+ const index = Number(row.dataset.index);
1425
+ const id = row.dataset.objectId;
1426
+ const actual = Math.round(axis === "block" ? row.offsetHeight : row.offsetWidth);
1427
+ const declared = placement.extentOf(index);
1428
+ if (extents.exact) {
1429
+ const key = `${id}:${declared}:${actual}`;
1430
+ if (actual !== declared && !reported.current.has(key)) {
1431
+ reported.current.add(key);
1432
+ onMismatch?.({
1433
+ index,
1434
+ id,
1435
+ declared,
1436
+ actual
1437
+ });
1438
+ }
1439
+ continue;
1440
+ }
1441
+ if (actual && actual !== declared) {
1442
+ placement.measure(id, actual);
1443
+ changed = true;
1444
+ }
1445
+ }
1446
+ if (placement.rebaseStart() !== 0) {
1447
+ const scroller = scrollerRef.current;
1448
+ if (scroller) {
1449
+ if (axis === "block") scroller.scrollTop = placement.scroll;
1450
+ else scroller.scrollLeft = placement.scroll;
1451
+ }
1452
+ changed = true;
1453
+ }
1454
+ const drift = placement.drift();
1455
+ const key = drift && `${drift.edge}:${drift.delta}`;
1456
+ if (drift && key && !reported.current.has(key)) {
1457
+ reported.current.add(key);
1458
+ onEdge?.(drift);
1459
+ }
1460
+ if (changed) invalidate();
1461
+ });
1462
+ const announced = useRef("");
1463
+ const state = `${visible.first}:${visible.last}:${first}:${last}:${count}`;
1464
+ if (onChange && announced.current !== state) {
1465
+ announced.current = state;
1466
+ const scroller = scrollerRef.current;
1467
+ const viewport = (axis === "block" ? scroller?.clientHeight : scroller?.clientWidth) ?? 0;
1468
+ const extent = windowExtentOf(placement, first, last);
1469
+ queueMicrotask(() => onChange({
1470
+ index: visible.first,
1471
+ visible,
1472
+ mounted: {
1473
+ first,
1474
+ last
1475
+ },
1476
+ count,
1477
+ geometry: {
1478
+ scroll: placement.scroll,
1479
+ viewport,
1480
+ total: sizerExtent,
1481
+ window: {
1482
+ start: offset,
1483
+ extent
1484
+ }
1485
+ }
1486
+ }));
1487
+ }
1488
+ return {
1489
+ placement,
1490
+ layout: {
1491
+ first,
1492
+ last,
1493
+ visible,
1494
+ offset,
1495
+ sizerExtent
1496
+ },
1497
+ windowRef,
1498
+ sizerExtent,
1499
+ offset,
1500
+ first,
1501
+ last
1502
+ };
1503
+ };
1504
+ /**
1505
+ * The DOM shape, and nothing else.
1506
+ *
1507
+ * Three elements: a **sizer** that holds no rows and exists only to give the thumb something to
1508
+ * measure, a **window** holding the mounted rows in normal flow, and the scroller around them.
1509
+ */
1510
+ var Window = ({ classNames, children, controllerRef, ...options }) => {
1511
+ const scrollerRef = useRef(null);
1512
+ const inner = useRef(null);
1513
+ const { placement, windowRef, offset, sizerExtent, first, last } = useWindow({
1514
+ ...options,
1515
+ scrollerRef,
1516
+ controllerRef: inner
1517
+ });
1518
+ const axis = options.axis ?? "block";
1519
+ const main = axis === "block" ? "height" : "width";
1520
+ const { getId } = options.model;
1521
+ const follow = useFollow({
1522
+ scrollerRef,
1523
+ placement,
1524
+ extent: sizerExtent,
1525
+ count: options.model.count,
1526
+ axis,
1527
+ reserve: options.reserve,
1528
+ enabled: options.sticky
1529
+ });
1530
+ useImperativeHandle(controllerRef, () => ({ scrollToIndex: (index, align, behavior) => {
1531
+ follow.onNavigate(index);
1532
+ inner.current?.scrollToIndex(index, align, behavior);
1533
+ } }), [follow]);
1534
+ const rows = [];
1535
+ for (let index = first; index <= last; index++) rows.push(/* @__PURE__ */ jsx("div", {
1536
+ "data-index": index,
1537
+ "data-object-id": getId(index),
1538
+ children: children(index, getId(index))
1539
+ }, getId(index)));
1540
+ return /* @__PURE__ */ jsxs("div", {
1541
+ ref: scrollerRef,
1542
+ className: mx("relative", axis === "block" ? "overflow-y-auto" : "overflow-x-auto", classNames),
1543
+ style: { overflowAnchor: "none" },
1544
+ "data-testid": "window.scroller",
1545
+ children: [/* @__PURE__ */ jsx("div", {
1546
+ style: { [main]: sizerExtent },
1547
+ "data-testid": "window.sizer"
1548
+ }), /* @__PURE__ */ jsx("div", {
1549
+ ref: windowRef,
1550
+ className: mx("absolute top-0 left-0 flex", axis === "block" ? "flex-col w-full" : "flex-row h-full"),
1551
+ style: { transform: axis === "block" ? `translateY(${offset}px)` : `translateX(${offset}px)` },
1552
+ "data-testid": "window.window",
1553
+ children: rows
1554
+ })]
1555
+ });
1556
+ };
1557
+ //#endregion
1558
+ //#region src/components/MessageList/position-log.ts
1559
+ /** Movement below this is sub-pixel rounding, not a row changing place. */
1560
+ var EPSILON = 1;
1561
+ /** Recent shifts kept for inspection — enough to see whether one row type moves or all of them. */
1562
+ var TRACE_LIMIT = 40;
1563
+ var EMPTY = {
1564
+ shifts: 0,
1565
+ breaks: 0
1566
+ };
1567
+ /**
1568
+ * Records where each row was placed, and reports when a row moves.
1569
+ *
1570
+ * A virtualized list is only still if a row's offset, once laid out, does not change: rows above the
1571
+ * reader are measured as they mount, and any correction there pushes everything below it. Scrolling
1572
+ * **up** is where this shows, because that is the direction in which unmeasured rows enter — going
1573
+ * down, the rows above have already been measured.
1574
+ *
1575
+ * Positions are recorded **relative to the viewport** (`start - scrollOffset`), not as absolute
1576
+ * document offsets, because those two answer different questions. Measuring a row for the first time
1577
+ * re-lays every row below it, and the virtualizer compensates by moving `scrollTop` the same
1578
+ * distance — the offsets all change and the reader sees nothing. What a reader sees is a row landing
1579
+ * somewhere else on screen, which is what this counts.
1580
+ *
1581
+ * Two invariants:
1582
+ *
1583
+ * 1. a row keyed by message id holds its place on screen for as long as it stays mounted;
1584
+ * 2. positions within the mounted window increase strictly with index.
1585
+ *
1586
+ * The count is what a reader means by "flicker". Deliberately kept as a count rather than a log:
1587
+ * a scroll produces thousands of samples, and the number is the signal.
1588
+ */
1589
+ /**
1590
+ * Watches for jumps in what was actually painted, once per animation frame.
1591
+ *
1592
+ * The counting above is done per React render, which cannot separate a row moving from the sampling
1593
+ * happening at a different moment than the layout. This reads the scroll and every row's box in the
1594
+ * **same** frame and compares each row's travel against the scroll's: a row that keeps its place in
1595
+ * the document travels exactly as far as the scroll did, so anything else is the reader seeing a
1596
+ * jump. It is the in-page form of measuring a screen recording frame by frame, and it is what a
1597
+ * developer should have running while working on the list.
1598
+ */
1599
+ var useJumpDetector = (viewport, enabled = true) => {
1600
+ const [jumps, setJumps] = useState({
1601
+ count: 0,
1602
+ worst: 0
1603
+ });
1604
+ useEffect(() => {
1605
+ if (!viewport || !enabled) return;
1606
+ let handle = 0;
1607
+ let previous = /* @__PURE__ */ new Map();
1608
+ let previousScroll = viewport.scrollTop;
1609
+ let count = 0;
1610
+ let worst = 0;
1611
+ const tick = () => {
1612
+ const scroll = viewport.scrollTop;
1613
+ const travelled = previousScroll - scroll;
1614
+ const current = /* @__PURE__ */ new Map();
1615
+ for (const row of viewport.querySelectorAll("[data-index]")) {
1616
+ const top = row.getBoundingClientRect().top;
1617
+ const index = row.dataset.index;
1618
+ current.set(index, top);
1619
+ const before = previous.get(index);
1620
+ if (before !== void 0) {
1621
+ const residual = Math.abs(top - (before + travelled));
1622
+ if (residual > EPSILON) {
1623
+ count++;
1624
+ worst = Math.max(worst, Math.round(residual));
1625
+ }
1626
+ }
1627
+ }
1628
+ previous = current;
1629
+ previousScroll = scroll;
1630
+ handle = requestAnimationFrame(tick);
1631
+ };
1632
+ handle = requestAnimationFrame(tick);
1633
+ const timer = setInterval(() => setJumps({
1634
+ count,
1635
+ worst
1636
+ }), 250);
1637
+ return () => {
1638
+ cancelAnimationFrame(handle);
1639
+ clearInterval(timer);
1640
+ };
1641
+ }, [viewport, enabled]);
1642
+ return jumps;
1643
+ };
1644
+ var usePositionLog = () => {
1645
+ const positions = useRef(/* @__PURE__ */ new Map());
1646
+ const trace = useRef([]);
1647
+ const [stats, setStats] = useState(EMPTY);
1648
+ const pending = useRef(EMPTY);
1649
+ const reset = useCallback(() => {
1650
+ positions.current.clear();
1651
+ trace.current = [];
1652
+ pending.current = EMPTY;
1653
+ setStats(EMPTY);
1654
+ }, []);
1655
+ const record = useCallback((entries, { scrollOffset }) => {
1656
+ let { shifts, breaks, last } = pending.current;
1657
+ let previousStart = -Infinity;
1658
+ for (const { key, index, start: absolute } of entries) {
1659
+ const start = absolute - scrollOffset;
1660
+ const recorded = positions.current.get(key);
1661
+ if (recorded !== void 0 && Math.abs(recorded - start) > EPSILON) {
1662
+ shifts++;
1663
+ last = {
1664
+ index,
1665
+ from: Math.round(recorded),
1666
+ to: Math.round(start),
1667
+ delta: Math.round(start - recorded)
1668
+ };
1669
+ trace.current.push(last);
1670
+ if (trace.current.length > TRACE_LIMIT) trace.current.shift();
1671
+ }
1672
+ positions.current.set(key, start);
1673
+ if (start < previousStart) breaks++;
1674
+ previousStart = start;
1675
+ }
1676
+ const mounted = new Set(entries.map(({ key }) => key));
1677
+ for (const key of positions.current.keys()) if (!mounted.has(key)) positions.current.delete(key);
1678
+ const next = {
1679
+ shifts,
1680
+ breaks,
1681
+ last
1682
+ };
1683
+ pending.current = next;
1684
+ setStats((previous) => previous.shifts === next.shifts && previous.breaks === next.breaks ? previous : { ...next });
1685
+ }, []);
1686
+ return {
1687
+ ...stats,
1688
+ trace,
1689
+ record,
1690
+ reset
1691
+ };
1692
+ };
1693
+ //#endregion
1694
+ //#region src/components/MessageList/MessageList.tsx
1695
+ var [MessageListProvider, useMessageListContext] = createContext$1("MessageList");
1696
+ /**
1697
+ * The list's state and scroll controls, for parts that live outside the viewport — a toolbar's
1698
+ * find-next, a statusbar's range readout.
1699
+ */
1700
+ var useMessageList = (consumerName = "useMessageList") => {
1701
+ const { range, currentIndex, navigation, mountedRows, mountedWidgets, jumps, shifts, breaks, resetShifts, model, scrollToIndex, scrollToBottom } = useMessageListContext(consumerName);
1702
+ return {
1703
+ range,
1704
+ currentIndex,
1705
+ navigation,
1706
+ mountedRows,
1707
+ mountedWidgets,
1708
+ jumps,
1709
+ shifts,
1710
+ breaks,
1711
+ resetShifts,
1712
+ model,
1713
+ count: model.count,
1714
+ scrollToIndex,
1715
+ scrollToBottom
1716
+ };
1717
+ };
1718
+ var DefaultChrome = ({ children }) => /* @__PURE__ */ jsx(Fragment$1, { children });
1719
+ /** Row height assumed before anything has been measured. */
1720
+ var DEFAULT_ESTIMATE = 120;
1721
+ /**
1722
+ * Headless root of a feed: owns the placement, the selection group and the model-to-item mapping.
1723
+ * Renders no DOM of its own, so a toolbar or statusbar that reads its state can sit outside the
1724
+ * scroll container — the scrolling part is `MessageList.Viewport`.
1725
+ */
1726
+ var MessageListRoot = ({ children, model, renderer = defaultRenderer, registry, Chrome = DefaultChrome, Custom, debug, estimateSize = DEFAULT_ESTIMATE, stickyBottom = false, scrollPastEnd = false, overscan = 8, onRangeChange }) => {
1727
+ const widgetStore = useMemo(createWidgetStateStore, []);
1728
+ const [viewport, setViewportState] = useState(null);
1729
+ const scrollerRef = useRef(null);
1730
+ const setViewport = useCallback((element) => {
1731
+ scrollerRef.current = element;
1732
+ setViewportState(element);
1733
+ }, []);
1734
+ const [range, setRange] = useState(void 0);
1735
+ const [mounted, setMounted] = useState(0);
1736
+ const nominalSize = typeof estimateSize === "number" ? estimateSize : DEFAULT_ESTIMATE;
1737
+ const [currentIndex, setCurrentIndex] = useState(0);
1738
+ const currentIndexRef = useRef(0);
1739
+ const setCurrent = useCallback((index) => {
1740
+ currentIndexRef.current = index;
1741
+ setCurrentIndex(index);
1742
+ }, []);
1743
+ const widgetCounts = useRef(/* @__PURE__ */ new Map());
1744
+ const [mountedWidgets, setMountedWidgets] = useState(0);
1745
+ const reportWidgets = useCallback((id, count) => {
1746
+ if (count) widgetCounts.current.set(id, count);
1747
+ else widgetCounts.current.delete(id);
1748
+ setMountedWidgets([...widgetCounts.current.values()].reduce((total, value) => total + value, 0));
1749
+ }, []);
1750
+ const extents = useMemo(() => ({ of: (index) => {
1751
+ const message = model.at(index);
1752
+ return typeof estimateSize === "function" && message ? estimateSize(message, index) : nominalSize;
1753
+ } }), [
1754
+ model,
1755
+ estimateSize,
1756
+ nominalSize
1757
+ ]);
1758
+ const reserve = useMemo(() => scrollPastEnd && viewport && model.count ? Math.max(0, viewport.clientHeight - nominalSize) : 0, [
1759
+ scrollPastEnd,
1760
+ viewport,
1761
+ model.count,
1762
+ nominalSize
1763
+ ]);
1764
+ const controller = useRef(null);
1765
+ const { placement, windowRef, offset, sizerExtent, first, last } = useWindow({
1766
+ scrollerRef,
1767
+ model,
1768
+ extents,
1769
+ overscan,
1770
+ reserve,
1771
+ onChange: useCallback((state) => {
1772
+ const next = state.count ? {
1773
+ startIndex: state.visible.first,
1774
+ endIndex: state.visible.last
1775
+ } : void 0;
1776
+ setRange(next);
1777
+ setMounted(state.mounted.last - state.mounted.first + 1);
1778
+ if (next) {
1779
+ onRangeChange?.(next);
1780
+ setCurrent(next.startIndex);
1781
+ if (next.startIndex === 0) model.more("start");
1782
+ if (next.endIndex >= model.count - 1) model.more("end");
1783
+ }
1784
+ }, [
1785
+ onRangeChange,
1786
+ setCurrent,
1787
+ model
1788
+ ]),
1789
+ controllerRef: controller
1790
+ });
1791
+ const follow = useFollow({
1792
+ scrollerRef,
1793
+ placement,
1794
+ extent: sizerExtent,
1795
+ count: model.count,
1796
+ reserve,
1797
+ enabled: stickyBottom
1798
+ });
1799
+ const jumps = useJumpDetector(viewport, debug);
1800
+ const { shifts, breaks, trace: shiftTrace, record: recordPositions, reset: resetShifts } = usePositionLog();
1801
+ useEffect(() => {
1802
+ const rows = [];
1803
+ for (let index = first; index <= last; index++) rows.push({
1804
+ key: model.getId(index),
1805
+ index,
1806
+ start: placement.positionOf(index)
1807
+ });
1808
+ recordPositions(rows, { scrollOffset: placement.scroll });
1809
+ }, [
1810
+ first,
1811
+ last,
1812
+ offset,
1813
+ sizerExtent,
1814
+ model,
1815
+ placement,
1816
+ recordPositions
1817
+ ]);
1818
+ const scrollToIndex = useCallback((index, { align = "start", behavior = "auto" } = {}) => {
1819
+ follow.onNavigate(index);
1820
+ controller.current?.scrollToIndex(index, align === "center" ? "start" : align, behavior);
1821
+ }, [follow]);
1822
+ const scrollToBottom = useCallback((options) => {
1823
+ if (model.count) scrollToIndex(model.count - 1, {
1824
+ align: "end",
1825
+ ...options
1826
+ });
1827
+ }, [scrollToIndex, model]);
1828
+ useEffect(() => {
1829
+ if (viewport) viewport.__feed = {
1830
+ placement,
1831
+ get layout() {
1832
+ return placement.layout();
1833
+ },
1834
+ get shifted() {
1835
+ return {
1836
+ shifts,
1837
+ breaks,
1838
+ recent: shiftTrace.current
1839
+ };
1840
+ }
1841
+ };
1842
+ }, [
1843
+ viewport,
1844
+ placement,
1845
+ shifts,
1846
+ breaks,
1847
+ shiftTrace
1848
+ ]);
1849
+ const navigation = useFeedNavigation({
1850
+ controller,
1851
+ stops: () => model.stops(),
1852
+ current: () => currentIndexRef.current,
1853
+ count: () => model.count,
1854
+ scrollPastEnd,
1855
+ onNavigate: follow.onNavigate
1856
+ });
1857
+ useEffect(() => {
1858
+ if (!viewport) return;
1859
+ viewport.tabIndex = 0;
1860
+ const onKeyDown = (event) => {
1861
+ if (event.altKey || event.shiftKey) return;
1862
+ const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
1863
+ if (!delta) return;
1864
+ event.preventDefault();
1865
+ if (event.metaKey || event.ctrlKey) {
1866
+ if (delta > 0) navigation.last();
1867
+ else navigation.first();
1868
+ return;
1869
+ }
1870
+ navigation.step(delta);
1871
+ };
1872
+ viewport.addEventListener("keydown", onKeyDown);
1873
+ return () => viewport.removeEventListener("keydown", onKeyDown);
1874
+ }, [viewport, navigation]);
1875
+ const selectionGroup = useMemo(createSelectionGroup, []);
1876
+ return /* @__PURE__ */ jsx(SelectionGroupContext.Provider, {
1877
+ value: selectionGroup,
1878
+ children: /* @__PURE__ */ jsx(WidgetStateProvider, {
1879
+ store: widgetStore,
1880
+ children: /* @__PURE__ */ jsx(MessageListProvider, {
1881
+ model,
1882
+ renderer,
1883
+ registry,
1884
+ Chrome,
1885
+ Custom,
1886
+ debug,
1887
+ range,
1888
+ currentIndex,
1889
+ navigation,
1890
+ mountedRows: mounted,
1891
+ mountedWidgets,
1892
+ reportWidgets,
1893
+ jumps,
1894
+ shifts,
1895
+ breaks,
1896
+ resetShifts,
1897
+ windowRef,
1898
+ offset,
1899
+ sizerExtent,
1900
+ first,
1901
+ last,
1902
+ setViewport,
1903
+ scrollToIndex,
1904
+ scrollToBottom,
1905
+ children
1906
+ })
1907
+ })
1908
+ });
1909
+ };
1910
+ MessageListRoot.displayName = "MessageList.Root";
1911
+ var MESSAGE_LIST_VIEWPORT_NAME = "MessageList.Viewport";
1912
+ /**
1913
+ * The scroll container and the mounted window of rows.
1914
+ *
1915
+ * Scrolling belongs to `ScrollArea`, which owns the overlay thumbs and the padding tokens; the
1916
+ * placement needs only the element being scrolled, which `ScrollArea.Viewport` publishes.
1917
+ */
1918
+ var MessageListViewport = composable(({ autoHide, centered, native, padding, scrollbars, thin, gutter, ...props }, forwardedRef) => {
1919
+ const { model, Chrome, windowRef, offset, sizerExtent, first, last, setViewport } = useMessageListContext(MESSAGE_LIST_VIEWPORT_NAME);
1920
+ const { selectedIds, onSelect } = useItemSelectionValue();
1921
+ const handleViewportRef = useCallback((element) => {
1922
+ setViewport(element);
1923
+ setRef(forwardedRef, element);
1924
+ }, [setViewport, forwardedRef]);
1925
+ const rows = [];
1926
+ for (let index = first; index <= last; index++) {
1927
+ const message = model.at(index);
1928
+ if (!message) continue;
1929
+ rows.push(/* @__PURE__ */ jsx("div", {
1930
+ "data-index": index,
1931
+ "data-object-id": message.id,
1932
+ children: /* @__PURE__ */ jsx(Column.Root, {
1933
+ gutter,
1934
+ children: /* @__PURE__ */ jsx(Column.Center, { children: /* @__PURE__ */ jsx(Chrome, {
1935
+ message,
1936
+ index,
1937
+ selected: selectedIds?.has(message.id) ?? false,
1938
+ onSelect: (id, additive) => onSelect?.(id, additive),
1939
+ children: /* @__PURE__ */ jsx(MessageListItem, { message })
1940
+ }) })
1941
+ })
1942
+ }, message.id));
1943
+ }
1944
+ return /* @__PURE__ */ jsx(ScrollArea.Root, {
1945
+ ...composableProps(props),
1946
+ orientation: "vertical",
1947
+ autoHide,
1948
+ centered,
1949
+ native,
1950
+ padding,
1951
+ scrollbars,
1952
+ thin,
1953
+ children: /* @__PURE__ */ jsxs(ScrollArea.Viewport, {
1954
+ "data-testid": "feed.viewport",
1955
+ style: {
1956
+ position: "relative",
1957
+ overflowAnchor: "none"
1958
+ },
1959
+ ref: handleViewportRef,
1960
+ children: [/* @__PURE__ */ jsx("div", {
1961
+ style: { height: sizerExtent },
1962
+ "data-testid": "feed.sizer"
1963
+ }), /* @__PURE__ */ jsx("div", {
1964
+ ref: windowRef,
1965
+ className: "absolute top-0 left-0 flex flex-col w-full",
1966
+ style: { transform: `translateY(${offset}px)` },
1967
+ "data-testid": "feed.window",
1968
+ children: rows
1969
+ })]
1970
+ })
1971
+ });
1972
+ });
1973
+ MessageListViewport.displayName = MESSAGE_LIST_VIEWPORT_NAME;
1974
+ var MESSAGE_LIST_ITEM_NAME = "MessageList.Item";
1975
+ /**
1976
+ * One message, rendered by the kind its renderer resolves. Exposed so a host can render a message
1977
+ * outside the scrolling window — a pinned message, a preview — through the same path.
1978
+ */
1979
+ var MessageListItem = composable(({ message, ...props }, forwardedRef) => {
1980
+ const { renderer, registry, Custom, debug, reportWidgets } = useMessageListContext(MESSAGE_LIST_ITEM_NAME);
1981
+ const content = renderer(message);
1982
+ const decorations = useDecorations(message.id);
1983
+ const hits = useMemo(() => decorations.length ? decorations.map(({ range }) => [range.offset, range.offset + range.length]) : void 0, [decorations]);
1984
+ const handleWidgetsChange = useCallback((count) => reportWidgets(message.id, count), [reportWidgets, message.id]);
1985
+ return /* @__PURE__ */ jsxs("div", {
1986
+ ...composableProps(props, { classNames: debug ? "outline outline-1 outline-dashed outline-primary-500/50 [&_.cm-content>*]:outline [&_.cm-content>*]:outline-1 [&_.cm-content>*]:outline-dashed [&_.cm-content>*]:outline-neutral-500/40" : void 0 }),
1987
+ ref: forwardedRef,
1988
+ children: [
1989
+ content.kind === "markdown" && /* @__PURE__ */ jsx(WidgetScopeProvider, {
1990
+ scope: message.id,
1991
+ children: /* @__PURE__ */ jsx(MarkdownBlock, {
1992
+ text: content.text,
1993
+ registry,
1994
+ hits,
1995
+ onWidgetsChange: handleWidgetsChange
1996
+ })
1997
+ }),
1998
+ content.kind === "html" && /* @__PURE__ */ jsx(HtmlBlock, { html: content.html }),
1999
+ content.kind === "custom" && Custom && /* @__PURE__ */ jsx(Custom, {
2000
+ content,
2001
+ message
2002
+ })
2003
+ ]
2004
+ });
2005
+ });
2006
+ MessageListItem.displayName = MESSAGE_LIST_ITEM_NAME;
2007
+ var MessageList = {
2008
+ Root: MessageListRoot,
2009
+ Viewport: MessageListViewport,
2010
+ Item: MessageListItem
2011
+ };
2012
+ //#endregion
2013
+ //#region src/components/Minimap/Minimap.tsx
2014
+ /** 2rem, matching the outline rail it usually sits opposite. */
2015
+ var DEFAULT_WIDTH$1 = 32;
2016
+ var Minimap = ({ classNames, state, width = DEFAULT_WIDTH$1, onSelect }) => {
2017
+ const total = state?.geometry.total ?? 0;
2018
+ const scale = (value) => total > 0 ? `${Math.max(0, Math.min(100, value / total * 100))}%` : "0%";
2019
+ return /* @__PURE__ */ jsx("div", {
2020
+ className: mx("relative h-full bg-input-surface overflow-hidden", onSelect && "cursor-pointer", classNames),
2021
+ style: { width },
2022
+ title: total ? `${Math.round(total)}px over ${state?.count} rows` : "empty",
2023
+ "data-testid": "minimap",
2024
+ onClick: onSelect && ((event) => {
2025
+ const box = event.currentTarget.getBoundingClientRect();
2026
+ onSelect(Math.max(0, Math.min(1, (event.clientY - box.top) / box.height)));
2027
+ }),
2028
+ children: state && total > 0 && /* @__PURE__ */ jsxs(Fragment$1, { children: [
2029
+ /* @__PURE__ */ jsx("div", {
2030
+ className: "absolute inset-x-0 bg-accent-fill/30",
2031
+ style: {
2032
+ top: scale(state.geometry.window.start),
2033
+ height: scale(state.geometry.window.extent)
2034
+ },
2035
+ "data-testid": "minimap.mounted"
2036
+ }),
2037
+ Array.from({ length: 9 }, (_, step) => /* @__PURE__ */ jsx("div", {
2038
+ className: "absolute inset-x-0 border-t border-separator/40",
2039
+ style: { top: `${(step + 1) / 10 * 100}%` }
2040
+ }, step)),
2041
+ /* @__PURE__ */ jsx("div", {
2042
+ className: "absolute inset-x-0 border-y border-accent-bg bg-accent-fill/70",
2043
+ style: {
2044
+ top: scale(state.geometry.scroll),
2045
+ height: scale(state.geometry.viewport)
2046
+ },
2047
+ "data-testid": "minimap.viewport"
2048
+ })
2049
+ ] })
2050
+ });
2051
+ };
2052
+ //#endregion
2053
+ //#region src/components/Outline/Outline.tsx
2054
+ var REST_WIDTH = 8;
2055
+ /** 2rem, matching the debug minimap it usually sits opposite. */
2056
+ var DEFAULT_WIDTH = 32;
2057
+ /** Height of one tick, and so the pitch the rail thins its markers to. */
2058
+ var TICK_SIZE = 8;
2059
+ var intersects = (range, visible) => range.from < visible.to && range.to > visible.from;
2060
+ /**
2061
+ * Evenly spreads `markers` over at most `capacity` rows, keeping the first and last.
2062
+ *
2063
+ * Each rendered tick then stands for the span up to the next one, so a thinned rail still covers
2064
+ * the whole document — a position between two ticks belongs to the one above it, rather than to
2065
+ * nothing.
2066
+ */
2067
+ var thin = (markers, capacity) => {
2068
+ const step = capacity > 1 ? (markers.length - 1) / (capacity - 1) : 0;
2069
+ const indexes = markers.length <= capacity ? markers.map((_, index) => index) : Array.from({ length: capacity }, (_, row) => Math.round(row * step));
2070
+ return indexes.map((index, row) => ({
2071
+ marker: markers[index],
2072
+ index,
2073
+ span: {
2074
+ from: markers[index].range.from,
2075
+ to: markers[indexes[row + 1]]?.range.from ?? markers[index].range.to
2076
+ }
2077
+ }));
2078
+ };
2079
+ var Outline = ({ classNames, markers, visibleRange, tickSize = TICK_SIZE, width = DEFAULT_WIDTH, onSelect, onNavigate }) => {
2080
+ const containerRef = useRef(null);
2081
+ const rowsRef = useRef([]);
2082
+ const [pointer, setPointer] = useState(null);
2083
+ const [navigated, setNavigated] = useState(null);
2084
+ const [available, setAvailable] = useState(0);
2085
+ useEffect(() => {
2086
+ const parent = containerRef.current?.parentElement;
2087
+ if (!parent) return;
2088
+ const observer = new ResizeObserver(([entry]) => setAvailable(entry.contentRect.height));
2089
+ observer.observe(parent);
2090
+ setAvailable(parent.getBoundingClientRect().height);
2091
+ return () => observer.disconnect();
2092
+ }, []);
2093
+ const natural = markers.length * tickSize;
2094
+ const bounded = available >= tickSize * 2;
2095
+ const rows = useMemo(() => thin(markers, bounded ? Math.min(markers.length, Math.floor(available / tickSize)) : markers.length), [
2096
+ markers,
2097
+ bounded,
2098
+ available,
2099
+ tickSize
2100
+ ]);
2101
+ const isActive = useCallback((row) => visibleRange ? intersects(row.span, visibleRange) : true, [visibleRange]);
2102
+ const [keyboard, setKeyboard] = useState(false);
2103
+ const shown = keyboard ? navigated ?? pointer : pointer ?? navigated;
2104
+ useEffect(() => {
2105
+ if (pointer == null) return;
2106
+ const rail = containerRef.current;
2107
+ const owner = rail?.ownerDocument;
2108
+ if (!rail || !owner) return;
2109
+ const onOver = (event) => {
2110
+ if (!rail.contains(event.target)) setPointer(null);
2111
+ };
2112
+ owner.addEventListener("pointerover", onOver);
2113
+ return () => owner.removeEventListener("pointerover", onOver);
2114
+ }, [pointer]);
2115
+ const [anchorOffset, setAnchorOffset] = useState(0);
2116
+ useLayoutEffect(() => {
2117
+ const row = shown == null ? null : rowsRef.current[shown];
2118
+ if (row) setAnchorOffset(row.offsetTop + row.offsetHeight / 2);
2119
+ }, [shown]);
2120
+ const widthFor = useCallback((index) => {
2121
+ if (shown == null) return REST_WIDTH;
2122
+ const distance = index - shown;
2123
+ const falloff = Math.exp(-(distance * distance) / 8);
2124
+ return REST_WIDTH + (width - REST_WIDTH) * falloff;
2125
+ }, [shown, width]);
2126
+ const handleKeyDown = useCallback((event) => {
2127
+ const delta = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
2128
+ if (!delta) return;
2129
+ if (onNavigate) {
2130
+ event.preventDefault();
2131
+ setKeyboard(true);
2132
+ onNavigate(delta);
2133
+ return;
2134
+ }
2135
+ const current = rowsRef.current.findIndex((element) => element === event.target);
2136
+ if (current < 0) return;
2137
+ const next = Math.min(Math.max(current + delta, 0), rows.length - 1);
2138
+ event.preventDefault();
2139
+ rowsRef.current[next]?.focus();
2140
+ }, [rows.length, onNavigate]);
2141
+ const visibleFrom = visibleRange?.from;
2142
+ useEffect(() => {
2143
+ if (!keyboard || visibleFrom == null) {
2144
+ setNavigated(null);
2145
+ return;
2146
+ }
2147
+ const index = rows.findIndex((row) => visibleFrom < row.span.to);
2148
+ setNavigated(index < 0 ? rows.length - 1 : index);
2149
+ }, [
2150
+ keyboard,
2151
+ visibleFrom,
2152
+ rows
2153
+ ]);
2154
+ const hoveredMarker = shown == null ? void 0 : rows[shown]?.marker;
2155
+ return /* @__PURE__ */ jsxs(Popover.Root, {
2156
+ open: hoveredMarker != null,
2157
+ children: [/* @__PURE__ */ jsxs("div", {
2158
+ role: "navigation",
2159
+ className: mx("relative flex flex-col justify-center overflow-hidden", classNames),
2160
+ style: {
2161
+ width: `${width}px`,
2162
+ height: bounded ? "100%" : `${natural}px`
2163
+ },
2164
+ "data-pointer": pointer ?? "",
2165
+ "data-navigated": navigated ?? "",
2166
+ "data-shown": shown ?? "",
2167
+ onPointerDown: () => setKeyboard(false),
2168
+ onBlurCapture: (event) => {
2169
+ if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
2170
+ },
2171
+ onKeyDown: handleKeyDown,
2172
+ ref: containerRef,
2173
+ children: [rows.map((row, index) => {
2174
+ const marker = row.marker;
2175
+ const active = isActive(row);
2176
+ return /* @__PURE__ */ jsx("button", {
2177
+ type: "button",
2178
+ "aria-label": marker.title,
2179
+ className: "flex items-center w-full shrink-0 cursor-pointer outline-none",
2180
+ style: { height: tickSize },
2181
+ ref: (element) => {
2182
+ rowsRef.current[index] = element;
2183
+ },
2184
+ onPointerEnter: () => {
2185
+ setPointer(index);
2186
+ setKeyboard(false);
2187
+ },
2188
+ onPointerLeave: () => setPointer((current) => current === index ? null : current),
2189
+ onFocus: () => keyboard && setNavigated(index),
2190
+ onClick: () => onSelect?.(marker, row.index),
2191
+ children: /* @__PURE__ */ jsx("div", {
2192
+ className: mx("h-[3px] rounded-full transition-all duration-200 ease-out", shown === index ? "bg-neutral-800 dark:bg-neutral-200" : "bg-neutral-400 dark:bg-neutral-600", active ? "opacity-100" : "opacity-30"),
2193
+ style: { width: widthFor(index) }
2194
+ })
2195
+ }, marker.id);
2196
+ }), /* @__PURE__ */ jsx(Popover.Anchor, {
2197
+ asChild: true,
2198
+ children: /* @__PURE__ */ jsx("div", {
2199
+ className: "absolute left-0",
2200
+ style: {
2201
+ top: anchorOffset,
2202
+ width,
2203
+ height: 0
2204
+ }
2205
+ })
2206
+ })]
2207
+ }), hoveredMarker && /* @__PURE__ */ jsxs(Popover.Content, {
2208
+ side: "right",
2209
+ align: "center",
2210
+ avoidCollisions: false,
2211
+ onOpenAutoFocus: (event) => event.preventDefault(),
2212
+ children: [/* @__PURE__ */ jsx(Popover.Viewport, { children: /* @__PURE__ */ jsxs("div", {
2213
+ className: "px-2 py-1 max-w-[24rem] w-[24rem]",
2214
+ children: [/* @__PURE__ */ jsx("p", {
2215
+ className: "truncate font-medium",
2216
+ children: hoveredMarker.title
2217
+ }), hoveredMarker.description && /* @__PURE__ */ jsx("p", {
2218
+ className: "mt-1 text-sm text-description line-clamp-3",
2219
+ children: hoveredMarker.description
2220
+ })]
2221
+ }) }), /* @__PURE__ */ jsx(Popover.Arrow, {})]
2222
+ }, hoveredMarker.id)]
2223
+ });
2224
+ };
2225
+ //#endregion
2226
+ export { ItemSelectionProvider as A, useWidgetState as C, SelectionGroupContext as D, setHighlights as E, useFeedNavigation as F, DecorationsProvider as I, useDecorations as L, useItemSelection as M, useItemSelectionValue as N, createSelectionGroup as O, useFollow as P, ScrollFollower as R, createWidgetStateStore as S, highlights as T, sliceFeed as _, useJumpDetector as a, WidgetScopeProvider as b, useFeedModel as c, fromMessages as d, ListModel as f, searchFeed as g, messageText as h, useMessageList as i, isAdditive as j, useSelectionGroup as k, useListModel as l, defaultRenderer as m, Minimap as n, usePositionLog as o, chatRenderer as p, MessageList as r, Window as s, Outline as t, FeedModel as u, MarkdownBlock as v, highlightTheme as w, WidgetStateProvider as x, HtmlBlock as y, stepVelocity as z };
2227
+
2228
+ //# sourceMappingURL=chunk-src.mjs.map