@intentface/chat 0.1.0

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 (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/ask-user/ask-user.d.ts +104 -0
  4. package/dist/ask-user/ask-user.js +226 -0
  5. package/dist/ask-user/index.d.ts +3 -0
  6. package/dist/ask-user/index.js +3 -0
  7. package/dist/ask-user/index.parts.d.ts +1 -0
  8. package/dist/ask-user/index.parts.js +5 -0
  9. package/dist/attachments/attachments.d.ts +41 -0
  10. package/dist/attachments/attachments.js +91 -0
  11. package/dist/attachments/index.d.ts +3 -0
  12. package/dist/attachments/index.js +3 -0
  13. package/dist/attachments/index.parts.d.ts +1 -0
  14. package/dist/attachments/index.parts.js +5 -0
  15. package/dist/chip/chip.d.ts +25 -0
  16. package/dist/chip/chip.js +25 -0
  17. package/dist/chip/index.d.ts +2 -0
  18. package/dist/chip/index.js +2 -0
  19. package/dist/chip/index.parts.d.ts +1 -0
  20. package/dist/chip/index.parts.js +5 -0
  21. package/dist/chip-markdown.d.ts +25 -0
  22. package/dist/chip-markdown.js +52 -0
  23. package/dist/composer/actions.d.ts +35 -0
  24. package/dist/composer/actions.js +75 -0
  25. package/dist/composer/ask-user-machine.d.ts +52 -0
  26. package/dist/composer/ask-user-machine.js +164 -0
  27. package/dist/composer/attachments-machine.d.ts +34 -0
  28. package/dist/composer/attachments-machine.js +91 -0
  29. package/dist/composer/attachments.d.ts +16 -0
  30. package/dist/composer/attachments.js +32 -0
  31. package/dist/composer/command-list.d.ts +49 -0
  32. package/dist/composer/command-list.js +357 -0
  33. package/dist/composer/commands.d.ts +28 -0
  34. package/dist/composer/commands.js +56 -0
  35. package/dist/composer/container.d.ts +3 -0
  36. package/dist/composer/container.js +32 -0
  37. package/dist/composer/controller.d.ts +3 -0
  38. package/dist/composer/controller.js +6 -0
  39. package/dist/composer/editor-dom.d.ts +55 -0
  40. package/dist/composer/editor-dom.js +431 -0
  41. package/dist/composer/fuzzy.d.ts +4 -0
  42. package/dist/composer/fuzzy.js +49 -0
  43. package/dist/composer/index.d.ts +25 -0
  44. package/dist/composer/index.js +13 -0
  45. package/dist/composer/index.parts.d.ts +11 -0
  46. package/dist/composer/index.parts.js +15 -0
  47. package/dist/composer/internals.d.ts +29 -0
  48. package/dist/composer/internals.js +102 -0
  49. package/dist/composer/keyboard.d.ts +61 -0
  50. package/dist/composer/keyboard.js +75 -0
  51. package/dist/composer/panel.d.ts +28 -0
  52. package/dist/composer/panel.js +80 -0
  53. package/dist/composer/placeholder.d.ts +10 -0
  54. package/dist/composer/placeholder.js +8 -0
  55. package/dist/composer/popover.d.ts +12 -0
  56. package/dist/composer/popover.js +63 -0
  57. package/dist/composer/prefix-detection.d.ts +23 -0
  58. package/dist/composer/prefix-detection.js +58 -0
  59. package/dist/composer/root.d.ts +21 -0
  60. package/dist/composer/root.js +107 -0
  61. package/dist/composer/segments.d.ts +108 -0
  62. package/dist/composer/segments.js +366 -0
  63. package/dist/composer/store.d.ts +97 -0
  64. package/dist/composer/store.js +451 -0
  65. package/dist/composer/textarea.d.ts +39 -0
  66. package/dist/composer/textarea.js +64 -0
  67. package/dist/composer/trigger-tracker.d.ts +12 -0
  68. package/dist/composer/trigger-tracker.js +90 -0
  69. package/dist/composer/types.d.ts +126 -0
  70. package/dist/composer/types.js +4 -0
  71. package/dist/composer/use-composer-editor.d.ts +43 -0
  72. package/dist/composer/use-composer-editor.js +649 -0
  73. package/dist/internal/collapsible.d.ts +22 -0
  74. package/dist/internal/collapsible.js +90 -0
  75. package/dist/internal/primitive-props.d.ts +4 -0
  76. package/dist/internal/primitive-props.js +4 -0
  77. package/dist/internal/render/baseTypes.d.ts +18 -0
  78. package/dist/internal/render/baseTypes.js +4 -0
  79. package/dist/internal/render/empty.d.ts +3 -0
  80. package/dist/internal/render/empty.js +5 -0
  81. package/dist/internal/render/getReactElementRef.d.ts +5 -0
  82. package/dist/internal/render/getReactElementRef.js +15 -0
  83. package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
  84. package/dist/internal/render/getStateAttributesProps.js +23 -0
  85. package/dist/internal/render/mergeObjects.d.ts +1 -0
  86. package/dist/internal/render/mergeObjects.js +14 -0
  87. package/dist/internal/render/mergeProps.d.ts +55 -0
  88. package/dist/internal/render/mergeProps.js +183 -0
  89. package/dist/internal/render/positioning.d.ts +22 -0
  90. package/dist/internal/render/positioning.js +78 -0
  91. package/dist/internal/render/reactVersion.d.ts +3 -0
  92. package/dist/internal/render/reactVersion.js +7 -0
  93. package/dist/internal/render/resolveClassName.d.ts +8 -0
  94. package/dist/internal/render/resolveClassName.js +12 -0
  95. package/dist/internal/render/resolveStyle.d.ts +8 -0
  96. package/dist/internal/render/resolveStyle.js +12 -0
  97. package/dist/internal/render/transition.d.ts +48 -0
  98. package/dist/internal/render/transition.js +203 -0
  99. package/dist/internal/render/types.d.ts +74 -0
  100. package/dist/internal/render/types.js +3 -0
  101. package/dist/internal/render/useMergedRefs.d.ts +21 -0
  102. package/dist/internal/render/useMergedRefs.js +102 -0
  103. package/dist/internal/render/useRefWithInit.d.ts +10 -0
  104. package/dist/internal/render/useRefWithInit.js +12 -0
  105. package/dist/internal/render/useRender.d.ts +71 -0
  106. package/dist/internal/render/useRender.js +12 -0
  107. package/dist/internal/render/useRenderElement.d.ts +62 -0
  108. package/dist/internal/render/useRenderElement.js +157 -0
  109. package/dist/internal/render/warn.d.ts +1 -0
  110. package/dist/internal/render/warn.js +15 -0
  111. package/dist/internal/state-mappings.d.ts +8 -0
  112. package/dist/internal/state-mappings.js +17 -0
  113. package/dist/message/index.d.ts +3 -0
  114. package/dist/message/index.js +3 -0
  115. package/dist/message/index.parts.d.ts +1 -0
  116. package/dist/message/index.parts.js +5 -0
  117. package/dist/message/message.d.ts +43 -0
  118. package/dist/message/message.js +78 -0
  119. package/dist/message-utils.d.ts +55 -0
  120. package/dist/message-utils.js +79 -0
  121. package/dist/reasoning/index.d.ts +3 -0
  122. package/dist/reasoning/index.js +3 -0
  123. package/dist/reasoning/index.parts.d.ts +1 -0
  124. package/dist/reasoning/index.parts.js +5 -0
  125. package/dist/reasoning/reasoning.d.ts +20 -0
  126. package/dist/reasoning/reasoning.js +49 -0
  127. package/dist/steps/index.d.ts +2 -0
  128. package/dist/steps/index.js +2 -0
  129. package/dist/steps/index.parts.d.ts +1 -0
  130. package/dist/steps/index.parts.js +5 -0
  131. package/dist/steps/steps.d.ts +53 -0
  132. package/dist/steps/steps.js +79 -0
  133. package/dist/thread/geometry.d.ts +18 -0
  134. package/dist/thread/geometry.js +59 -0
  135. package/dist/thread/index.d.ts +4 -0
  136. package/dist/thread/index.js +3 -0
  137. package/dist/thread/index.parts.d.ts +1 -0
  138. package/dist/thread/index.parts.js +5 -0
  139. package/dist/thread/stores.d.ts +21 -0
  140. package/dist/thread/stores.js +63 -0
  141. package/dist/thread/thread.d.ts +66 -0
  142. package/dist/thread/thread.js +639 -0
  143. package/dist/types.d.ts +58 -0
  144. package/dist/types.js +17 -0
  145. package/package.json +106 -0
@@ -0,0 +1,639 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Headless thread: the scroll subsystem (at-bottom detection, auto-scroll
4
+ // landing/follow, composer-inset measurement) plus unstyled structural parts.
5
+ // The only styles emitted are behavior-critical (positioning/overflow/sentinel
6
+ // size), applied inline; everything visual belongs to the styled layer.
7
+ //
8
+ // Styled-layer contract: Thread.Content sets --thread-turn-min-height while an
9
+ // auto-scroll mode is active; map it onto the last child's min-height (e.g.
10
+ // `[&>*:last-child]:min-h-(--thread-turn-min-height,0px)`) so the newest turn
11
+ // can land at the top.
12
+ import { createContext, memo, use, useCallback, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
13
+ import { useRefWithInit } from "../internal/render/useRefWithInit.js";
14
+ import { useRenderElement } from "../internal/render/useRenderElement.js";
15
+ import { DEFAULT_BOTTOM_OFFSET, DEFAULT_DOCK_SELECTOR, measureDockInset, measureTopInset, queryDockParts, resolveScrollBehavior, scrollContainerTo, wasPrepended, } from "./geometry.js";
16
+ import { createEdgeStore, createVisibilityStore, EMPTY_VISIBILITY, } from "./stores.js";
17
+ // Latest-ref: read the current value from long-lived effects/callbacks without
18
+ // re-subscribing them when it changes.
19
+ const useAsRef = (value) => {
20
+ const ref = useRef(value);
21
+ useInsertionEffect(() => {
22
+ ref.current = value;
23
+ }, [value]);
24
+ return ref;
25
+ };
26
+ const ThreadContext = createContext(null);
27
+ const useThreadContext = () => {
28
+ const ctx = use(ThreadContext);
29
+ if (!ctx)
30
+ throw new Error("useThread must be used within <Thread>");
31
+ return ctx;
32
+ };
33
+ /**
34
+ * Public thread surface. Subscribes to the scroll edges, so call it where
35
+ * isAtTop / isAtBottom are actually read (e.g. a scroll button or a load-older
36
+ * trigger); the commands and refs are stable and never cause re-renders.
37
+ */
38
+ export const useThread = () => {
39
+ const { atTopStore, atBottomStore, scrollToBottom, scrollToTop, scrollToMessage, scrollRef, contentRef, } = useThreadContext();
40
+ const isAtTop = useSyncExternalStore(atTopStore.subscribe, atTopStore.getSnapshot, atTopStore.getSnapshot);
41
+ const isAtBottom = useSyncExternalStore(atBottomStore.subscribe, atBottomStore.getSnapshot, atBottomStore.getSnapshot);
42
+ return {
43
+ isAtTop,
44
+ isAtBottom,
45
+ scrollToBottom,
46
+ scrollToTop,
47
+ scrollToMessage,
48
+ scrollRef,
49
+ contentRef,
50
+ };
51
+ };
52
+ /**
53
+ * Which data-message-id rows are in view, plus the topmost one (the row being
54
+ * read). Subscribing lazily spins up the tracking observers; when the last
55
+ * subscriber unmounts they are torn down, so unused threads pay nothing.
56
+ */
57
+ export const useThreadVisibility = () => {
58
+ const { visibilityStore, observeVisibility, unobserveVisibility } = useThreadContext();
59
+ const subscribe = useCallback((listener) => visibilityStore.subscribe(listener, observeVisibility, unobserveVisibility), [visibilityStore, observeVisibility, unobserveVisibility]);
60
+ return useSyncExternalStore(subscribe, visibilityStore.getSnapshot, visibilityStore.getSnapshot);
61
+ };
62
+ // Keys that scroll the viewport *upward* and therefore count as deliberate
63
+ // reading intent, releasing the follow. Downward keys never release: at the
64
+ // bottom they cause no scroll (and so no re-arming at-bottom transition), so
65
+ // releasing on them would strand the view unfollowed while visually pinned.
66
+ const USER_SCROLL_UP_KEYS = new Set(["ArrowUp", "PageUp", "Home"]);
67
+ // How long after a programmatic scroll the sentinel may leave view without
68
+ // releasing the follow. Cleared earlier by scrollend where supported; the
69
+ // timeout is the Safari fallback.
70
+ const AUTO_SCROLL_SETTLE_MS = 200;
71
+ // Sub-pixel scrollTop rounding differs across engines; a small IntersectionObserver
72
+ // rootMargin keeps edge detection from flickering right at the top/bottom boundary.
73
+ const EDGE_TOLERANCE = "8px 0px";
74
+ const useThreadScroll = (rootRef, mode, preserveScrollOnPrepend) => {
75
+ const scrollRef = useRef(null);
76
+ const contentRef = useRef(null);
77
+ const topSentinelRef = useRef(null);
78
+ const bottomSentinelRef = useRef(null);
79
+ // Edge state = each sentinel is in view. IntersectionObserver computes it off
80
+ // the main thread (no scrollTop/scrollHeight reads); the bottom edge drives
81
+ // the scroll button and re-arms the follow, the top edge drives load-older.
82
+ const atTopStore = useRefWithInit(createEdgeStore).current;
83
+ const atBottomStore = useRefWithInit(createEdgeStore).current;
84
+ // Follow intent: true while the view should track streaming growth.
85
+ const followingRef = useRef(true);
86
+ // Read by the landing observer without re-running it (a re-run re-lands).
87
+ const preserveOnPrependRef = useAsRef(preserveScrollOnPrepend);
88
+ // True while a scroll we started may still be in flight.
89
+ const autoScrollingRef = useRef(false);
90
+ const autoScrollingTimeoutRef = useRef(null);
91
+ const markAutoScrolling = useCallback(() => {
92
+ autoScrollingRef.current = true;
93
+ if (autoScrollingTimeoutRef.current !== null) {
94
+ window.clearTimeout(autoScrollingTimeoutRef.current);
95
+ }
96
+ autoScrollingTimeoutRef.current = window.setTimeout(() => {
97
+ autoScrollingTimeoutRef.current = null;
98
+ autoScrollingRef.current = false;
99
+ }, AUTO_SCROLL_SETTLE_MS);
100
+ }, []);
101
+ useEffect(() => {
102
+ const scroller = scrollRef.current;
103
+ const rootElement = rootRef.current;
104
+ const topSentinel = topSentinelRef.current;
105
+ const bottomSentinel = bottomSentinelRef.current;
106
+ if (!scroller || !topSentinel || !bottomSentinel)
107
+ return;
108
+ // One observer for both edge sentinels; its `root` is the scroll container.
109
+ // Edge state is mirrored to data-at-top / data-at-bottom on the thread root
110
+ // (the common ancestor of the overlays + scroller) for pure-CSS affordances.
111
+ const io = new IntersectionObserver((entries) => {
112
+ for (const entry of entries) {
113
+ if (entry.target === topSentinel) {
114
+ const atTop = entry.isIntersecting;
115
+ atTopStore.setSnapshot(atTop);
116
+ rootElement?.toggleAttribute("data-at-top", atTop);
117
+ continue;
118
+ }
119
+ const atBottom = entry.isIntersecting;
120
+ if (atBottom) {
121
+ atBottomStore.setSnapshot(true);
122
+ followingRef.current = true;
123
+ rootElement?.toggleAttribute("data-at-bottom", true);
124
+ }
125
+ else if (!autoScrollingRef.current) {
126
+ // The sentinel left view and we didn't cause it: a scrollbar drag,
127
+ // a momentum scroll away, or content growing past the live edge in a
128
+ // non-following mode. A scroll *we* started (send landing / followed
129
+ // stream) keeps autoScrollingRef set, so its transient off-screen
130
+ // frame is ignored — that's what stops the button flashing on send.
131
+ atBottomStore.setSnapshot(false);
132
+ followingRef.current = false;
133
+ rootElement?.toggleAttribute("data-at-bottom", false);
134
+ }
135
+ }
136
+ }, { root: scroller, rootMargin: EDGE_TOLERANCE });
137
+ io.observe(topSentinel);
138
+ io.observe(bottomSentinel);
139
+ // A finished scroll means nothing of ours is in flight anymore; clear
140
+ // early instead of waiting out the timeout fallback.
141
+ const settle = () => {
142
+ autoScrollingRef.current = false;
143
+ if (autoScrollingTimeoutRef.current !== null) {
144
+ window.clearTimeout(autoScrollingTimeoutRef.current);
145
+ autoScrollingTimeoutRef.current = null;
146
+ }
147
+ };
148
+ scroller.addEventListener("scrollend", settle);
149
+ return () => {
150
+ io.disconnect();
151
+ scroller.removeEventListener("scrollend", settle);
152
+ if (autoScrollingTimeoutRef.current !== null) {
153
+ window.clearTimeout(autoScrollingTimeoutRef.current);
154
+ autoScrollingTimeoutRef.current = null;
155
+ }
156
+ };
157
+ }, [atTopStore, atBottomStore, rootRef]);
158
+ const scrollToBottom = useCallback((behavior = "smooth") => {
159
+ const el = scrollRef.current;
160
+ if (!el)
161
+ return;
162
+ markAutoScrolling();
163
+ scrollContainerTo(el, el.scrollHeight, behavior);
164
+ }, [markAutoScrolling]);
165
+ // Scrolling to the top is a deliberate move away from the live end, so it
166
+ // releases the follow; reaching the bottom again re-arms it.
167
+ const scrollToTop = useCallback((behavior = "smooth") => {
168
+ const el = scrollRef.current;
169
+ if (!el)
170
+ return;
171
+ followingRef.current = false;
172
+ markAutoScrolling();
173
+ scrollContainerTo(el, 0, behavior);
174
+ }, [markAutoScrolling]);
175
+ // Resolve a row by its consumer-provided data-message-id and jump to it.
176
+ // Resolved lazily at call time — no per-row registration, nothing on the hot
177
+ // path. scrollIntoView handles the alignment math and honors any
178
+ // scroll-margin the styled layer sets on rows.
179
+ const resolveMessageJump = useCallback((messageId, options = {}) => {
180
+ const row = contentRef.current?.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
181
+ if (!row)
182
+ return false;
183
+ followingRef.current = false;
184
+ markAutoScrolling();
185
+ row.scrollIntoView({
186
+ block: options.align ?? "start",
187
+ inline: "nearest",
188
+ behavior: resolveScrollBehavior(options.behavior ?? "smooth"),
189
+ });
190
+ return true;
191
+ }, [markAutoScrolling]);
192
+ // Pending jump — a scrollToMessage that arrived before the transcript did
193
+ // (a deep link while messages load asynchronously). Held until the first
194
+ // rows mount, then flushed; the watching observer exists only while a jump
195
+ // is queued, so the queue costs nothing when unused.
196
+ const pendingJumpRef = useRef(null);
197
+ const pendingJumpObserverRef = useRef(null);
198
+ const clearPendingJump = useCallback(() => {
199
+ pendingJumpRef.current = null;
200
+ pendingJumpObserverRef.current?.disconnect();
201
+ pendingJumpObserverRef.current = null;
202
+ }, []);
203
+ // Settle the queued jump once rows exist: jump if the id mounted, or drop
204
+ // the request if the transcript loaded without it — a stale or foreign id
205
+ // must not hijack a later chat. Keeps waiting while no rows are mounted.
206
+ const flushPendingJump = useCallback(() => {
207
+ const pending = pendingJumpRef.current;
208
+ const content = contentRef.current;
209
+ if (!pending || !content)
210
+ return false;
211
+ if (!content.querySelector("[data-message-id]"))
212
+ return false;
213
+ const jumped = resolveMessageJump(pending.messageId, pending.options);
214
+ clearPendingJump();
215
+ return jumped;
216
+ }, [resolveMessageJump, clearPendingJump]);
217
+ useEffect(() => clearPendingJump, [clearPendingJump]);
218
+ // Returns true when the jump ran or was queued; false only when the id is
219
+ // absent from an already-loaded transcript.
220
+ const scrollToMessage = useCallback((messageId, options = {}) => {
221
+ if (resolveMessageJump(messageId, options)) {
222
+ clearPendingJump();
223
+ return true;
224
+ }
225
+ const content = contentRef.current;
226
+ // Queue only while the transcript has produced no rows yet (an async
227
+ // load in flight). An id missing from a loaded transcript is just absent.
228
+ if (!content || content.querySelector("[data-message-id]"))
229
+ return false;
230
+ pendingJumpRef.current = { messageId, options };
231
+ if (!pendingJumpObserverRef.current) {
232
+ const observer = new MutationObserver(() => {
233
+ flushPendingJump();
234
+ });
235
+ // subtree: rows usually mount inside turn wrappers, not as direct
236
+ // children of the content column.
237
+ observer.observe(content, { childList: true, subtree: true });
238
+ pendingJumpObserverRef.current = observer;
239
+ }
240
+ return true;
241
+ }, [resolveMessageJump, clearPendingJump, flushPendingJump]);
242
+ const releaseFollow = useCallback(() => {
243
+ followingRef.current = false;
244
+ }, []);
245
+ useLayoutEffect(() => {
246
+ const content = contentRef.current;
247
+ if (!content || mode === "off")
248
+ return;
249
+ const landsAtTop = mode !== "bottom";
250
+ const followsStream = mode !== "jump";
251
+ // Reserve a viewport on the last turn so the newest lands at the top.
252
+ if (landsAtTop) {
253
+ content.style.setProperty("--thread-turn-min-height", "var(--thread-turn-area)");
254
+ }
255
+ // First land (and chat switches) jump instantly; later turns animate. A
256
+ // land is a deliberate move to the live end, so it re-arms the follow.
257
+ // Prepends are not new turns: when preservation is on, skip the land and
258
+ // let the preserve effect hold the reading position instead.
259
+ let landed = false;
260
+ let skipNextResize = true;
261
+ let hadContent = false;
262
+ let firstTurn = null;
263
+ const land = (mutations = []) => {
264
+ const previousFirst = firstTurn;
265
+ firstTurn = content.firstElementChild;
266
+ // A switch whose history loads async goes A → [] → B across separate
267
+ // commits, so the add-only commit isn't flagged `replaced`. Treat "was
268
+ // empty, now populated" as a fresh land (instant), not an incremental
269
+ // turn — shadcn's `previousItemCount === 0` rule, read off the DOM since
270
+ // the headless thread owns no item count. Tracked before the early
271
+ // returns so the prepend / deep-link-jump paths keep it accurate.
272
+ const hasContent = content.children.length > 0;
273
+ const repopulated = hasContent && !hadContent;
274
+ hadContent = hasContent;
275
+ if (preserveOnPrependRef.current && wasPrepended(mutations, previousFirst, content)) {
276
+ return;
277
+ }
278
+ // A queued deep-link jump owns the landing: landing at the bottom would
279
+ // race the jump the consumer asked for. Hold while the transcript is
280
+ // still empty; once rows exist the flush either jumps (skip the land) or
281
+ // drops a stale id (fall through and land normally).
282
+ if (pendingJumpRef.current) {
283
+ if (flushPendingJump()) {
284
+ landed = true;
285
+ return;
286
+ }
287
+ if (pendingJumpRef.current)
288
+ return;
289
+ }
290
+ const replaced = mutations.some((m) => m.removedNodes.length > 0);
291
+ skipNextResize = true;
292
+ followingRef.current = true;
293
+ scrollToBottom(landed && !replaced && !repopulated ? "smooth" : "instant");
294
+ landed = true;
295
+ };
296
+ // Follow streaming growth, but skip the resize our own land just caused
297
+ // and yield once the follow is released. Gate on the intent ref ONLY —
298
+ // never on the at-bottom snapshot: IntersectionObserver reports a frame
299
+ // late, so a position check here would scroll on stale "at bottom" the
300
+ // instant after the user wheels up, hijacking their scroll and re-arming
301
+ // the follow in a loop they can't escape.
302
+ const follow = () => {
303
+ if (skipNextResize) {
304
+ skipNextResize = false;
305
+ return;
306
+ }
307
+ if (followingRef.current) {
308
+ scrollToBottom("smooth");
309
+ }
310
+ };
311
+ land();
312
+ const turns = new MutationObserver(land);
313
+ turns.observe(content, { childList: true });
314
+ const growth = followsStream ? new ResizeObserver(follow) : null;
315
+ growth?.observe(content);
316
+ return () => {
317
+ turns.disconnect();
318
+ growth?.disconnect();
319
+ if (landsAtTop)
320
+ content.style.removeProperty("--thread-turn-min-height");
321
+ };
322
+ }, [mode, scrollToBottom, flushPendingJump]);
323
+ // Prepend preservation — hold the reading position while older history loads
324
+ // in above. Native scroll anchoring (Chrome/Firefox) already keeps the
325
+ // viewport-relative position; comparing against the captured anchor makes the
326
+ // restore a no-op there and corrects the engines that don't (Safari). Opt-in:
327
+ // the passive scroll listener (a binary search over row rects, O(log n) reads
328
+ // against already-computed layout) only exists while the prop is set.
329
+ useLayoutEffect(() => {
330
+ if (!preserveScrollOnPrepend)
331
+ return;
332
+ const scroller = scrollRef.current;
333
+ const content = contentRef.current;
334
+ if (!scroller || !content)
335
+ return;
336
+ // Rows are vertically ordered, so the first row crossing the viewport top
337
+ // is found by binary search — no full scan.
338
+ const findFirstVisibleRow = () => {
339
+ const rows = content.children;
340
+ const viewportTop = scroller.getBoundingClientRect().top;
341
+ let low = 0;
342
+ let high = rows.length - 1;
343
+ let found = null;
344
+ while (low <= high) {
345
+ const mid = (low + high) >> 1;
346
+ const row = rows[mid];
347
+ if (row.getBoundingClientRect().bottom > viewportTop) {
348
+ found = row;
349
+ high = mid - 1;
350
+ }
351
+ else {
352
+ low = mid + 1;
353
+ }
354
+ }
355
+ return found;
356
+ };
357
+ let anchor = null;
358
+ const capture = () => {
359
+ const element = findFirstVisibleRow();
360
+ anchor = element
361
+ ? {
362
+ element,
363
+ viewportTop: element.getBoundingClientRect().top - scroller.getBoundingClientRect().top,
364
+ }
365
+ : null;
366
+ };
367
+ const restore = () => {
368
+ if (!anchor || !anchor.element.isConnected)
369
+ return;
370
+ const delta = anchor.element.getBoundingClientRect().top -
371
+ scroller.getBoundingClientRect().top -
372
+ anchor.viewportTop;
373
+ if (Math.abs(delta) > 0.5)
374
+ scroller.scrollTop += delta;
375
+ };
376
+ let firstTurn = content.firstElementChild;
377
+ const observer = new MutationObserver((mutations) => {
378
+ const previousFirst = firstTurn;
379
+ firstTurn = content.firstElementChild;
380
+ if (wasPrepended(mutations, previousFirst, content))
381
+ restore();
382
+ capture();
383
+ });
384
+ observer.observe(content, { childList: true });
385
+ capture();
386
+ scroller.addEventListener("scroll", capture, { passive: true });
387
+ return () => {
388
+ observer.disconnect();
389
+ scroller.removeEventListener("scroll", capture);
390
+ };
391
+ }, [preserveScrollOnPrepend]);
392
+ // Visibility tracking — created lazily by the first useThreadVisibility
393
+ // subscriber, torn down with the last. An IntersectionObserver maintains the
394
+ // set of intersecting rows; snapshots are rebuilt on a coalesced frame by
395
+ // filtering rows in document order (a DOM query, no layout reads).
396
+ const visibilityStore = useRefWithInit(createVisibilityStore).current;
397
+ const visibleIdsRef = useRef(new Set());
398
+ const visibilityObserverRef = useRef(null);
399
+ const visibilityRowsObserverRef = useRef(null);
400
+ const visibilityFrameRef = useRef(null);
401
+ const syncVisibility = useCallback(() => {
402
+ if (visibilityFrameRef.current !== null)
403
+ return;
404
+ visibilityFrameRef.current = requestAnimationFrame(() => {
405
+ visibilityFrameRef.current = null;
406
+ // A frame can outlive the last unsubscribe; recomputing would overwrite
407
+ // the empty snapshot teardown just wrote.
408
+ if (!visibilityStore.hasListeners())
409
+ return;
410
+ const content = contentRef.current;
411
+ if (!content)
412
+ return;
413
+ const visible = [];
414
+ for (const row of content.querySelectorAll("[data-message-id]")) {
415
+ const id = row.dataset.messageId;
416
+ if (id && visibleIdsRef.current.has(id))
417
+ visible.push(id);
418
+ }
419
+ visibilityStore.setSnapshot({
420
+ visibleMessageIds: visible,
421
+ currentMessageId: visible[0] ?? null,
422
+ });
423
+ });
424
+ }, [visibilityStore]);
425
+ const observeVisibility = useCallback(() => {
426
+ const scroller = scrollRef.current;
427
+ const content = contentRef.current;
428
+ if (!scroller || !content)
429
+ return;
430
+ visibilityObserverRef.current = new IntersectionObserver((entries) => {
431
+ for (const entry of entries) {
432
+ const id = entry.target.dataset.messageId;
433
+ if (!id)
434
+ continue;
435
+ if (entry.isIntersecting)
436
+ visibleIdsRef.current.add(id);
437
+ else
438
+ visibleIdsRef.current.delete(id);
439
+ }
440
+ syncVisibility();
441
+ }, { root: scroller });
442
+ // Rows mount and unmount as the transcript changes; re-observe only when
443
+ // the row list itself changed — streaming inside a row is ignored.
444
+ let observedRows = [];
445
+ const reconcileRows = () => {
446
+ const rows = Array.from(content.querySelectorAll("[data-message-id]"));
447
+ if (rows.length === observedRows.length &&
448
+ rows.every((row, index) => row === observedRows[index])) {
449
+ return;
450
+ }
451
+ observedRows = rows;
452
+ const mountedIds = new Set(rows.map((row) => row.dataset.messageId ?? ""));
453
+ for (const id of visibleIdsRef.current) {
454
+ if (!mountedIds.has(id))
455
+ visibleIdsRef.current.delete(id);
456
+ }
457
+ const io = visibilityObserverRef.current;
458
+ io?.disconnect();
459
+ for (const row of rows)
460
+ io?.observe(row);
461
+ syncVisibility();
462
+ };
463
+ visibilityRowsObserverRef.current = new MutationObserver(reconcileRows);
464
+ visibilityRowsObserverRef.current.observe(content, { childList: true, subtree: true });
465
+ reconcileRows();
466
+ }, [syncVisibility]);
467
+ const unobserveVisibility = useCallback(() => {
468
+ if (visibilityFrameRef.current !== null) {
469
+ cancelAnimationFrame(visibilityFrameRef.current);
470
+ visibilityFrameRef.current = null;
471
+ }
472
+ visibilityObserverRef.current?.disconnect();
473
+ visibilityObserverRef.current = null;
474
+ visibilityRowsObserverRef.current?.disconnect();
475
+ visibilityRowsObserverRef.current = null;
476
+ visibleIdsRef.current.clear();
477
+ visibilityStore.setSnapshot(EMPTY_VISIBILITY);
478
+ }, [visibilityStore]);
479
+ // Safety net: subscribers normally tear tracking down on their own unmount,
480
+ // but the thread itself can unmount first.
481
+ useEffect(() => unobserveVisibility, [unobserveVisibility]);
482
+ return useMemo(() => ({
483
+ atTopStore,
484
+ atBottomStore,
485
+ visibilityStore,
486
+ observeVisibility,
487
+ unobserveVisibility,
488
+ scrollToBottom,
489
+ scrollToTop,
490
+ scrollToMessage,
491
+ releaseFollow,
492
+ scrollRef,
493
+ contentRef,
494
+ topSentinelRef,
495
+ bottomSentinelRef,
496
+ }), [
497
+ atTopStore,
498
+ atBottomStore,
499
+ visibilityStore,
500
+ observeVisibility,
501
+ unobserveVisibility,
502
+ scrollToBottom,
503
+ scrollToTop,
504
+ scrollToMessage,
505
+ releaseFollow,
506
+ ]);
507
+ };
508
+ // ---------------------------------------------------------------------------
509
+ // Insets
510
+ // ---------------------------------------------------------------------------
511
+ /**
512
+ * Writes the measured insets to CSS vars on the root via a ResizeObserver — no
513
+ * React state, so composer growth never re-renders the thread:
514
+ * --thread-overlay-bottom-height drives the bottom overlay + viewport padding;
515
+ * --thread-turn-area is the visible thread area (root − top − bottom). When an
516
+ * auto-scroll mode is active, useThreadScroll maps the last turn's reserve
517
+ * (--thread-turn-min-height) to it; otherwise the reserve falls back to 0.
518
+ * Recomputes only on root (window) / composer-dock resize — never per token.
519
+ */
520
+ const useThreadInsets = (rootRef, dockSelector) => {
521
+ useLayoutEffect(() => {
522
+ const root = rootRef.current;
523
+ if (!root)
524
+ return;
525
+ const apply = () => {
526
+ const bottomInset = measureDockInset(root, dockSelector);
527
+ if (bottomInset !== null) {
528
+ root.style.setProperty("--thread-overlay-bottom-height", `${bottomInset}px`);
529
+ }
530
+ const topInset = measureTopInset(root);
531
+ const area = Math.max(0, Math.round(root.clientHeight - topInset - (bottomInset ?? DEFAULT_BOTTOM_OFFSET)));
532
+ root.style.setProperty("--thread-turn-area", `${area}px`);
533
+ };
534
+ apply();
535
+ const observer = new ResizeObserver(apply);
536
+ observer.observe(root);
537
+ for (const part of queryDockParts(root, dockSelector)) {
538
+ observer.observe(part);
539
+ }
540
+ return () => observer.disconnect();
541
+ }, [dockSelector, rootRef]);
542
+ };
543
+ export const ThreadRoot = ({ autoScroll = "follow", preserveScrollOnPrepend = false, dockSelector = DEFAULT_DOCK_SELECTOR, className, render, style, ...elementProps }) => {
544
+ const rootRef = useRef(null);
545
+ useThreadInsets(rootRef, dockSelector);
546
+ const scroll = useThreadScroll(rootRef, autoScroll, preserveScrollOnPrepend);
547
+ const element = useRenderElement("div", { className, render, style }, {
548
+ ref: rootRef,
549
+ props: [
550
+ {
551
+ "data-thread-root": "",
552
+ // Anchors the overlays/composer and bounds the inset measurement.
553
+ style: { position: "relative", overflow: "hidden" },
554
+ },
555
+ elementProps,
556
+ ],
557
+ });
558
+ return _jsx(ThreadContext, { value: scroll, children: element });
559
+ };
560
+ const threadOverlayStateMapping = {
561
+ direction: (value) => ({ "data-thread-overlay": value }),
562
+ };
563
+ export const ThreadOverlay = memo(({ direction, className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, {
564
+ state: { direction },
565
+ stateAttributesMapping: threadOverlayStateMapping,
566
+ props: [
567
+ {
568
+ // Identity + direction ride the data-thread-overlay state attribute
569
+ // (stamped by threadOverlayStateMapping above).
570
+ // A sibling of the scroll container: never swallow wheel/drag/click.
571
+ style: { position: "absolute", pointerEvents: "none" },
572
+ },
573
+ elementProps,
574
+ ],
575
+ }));
576
+ ThreadOverlay.displayName = "ThreadOverlay";
577
+ export const ThreadViewport = ({ className, render, style, ...elementProps }) => {
578
+ const { scrollRef, releaseFollow } = useThreadContext();
579
+ const lastTouchYRef = useRef(0);
580
+ const handleWheel = useCallback((event) => {
581
+ if (event.deltaY < 0)
582
+ releaseFollow();
583
+ }, [releaseFollow]);
584
+ const handleTouchStart = useCallback((event) => {
585
+ lastTouchYRef.current = event.touches[0]?.clientY ?? 0;
586
+ }, []);
587
+ // A finger moving down drags the content down — an upward scroll.
588
+ const handleTouchMove = useCallback((event) => {
589
+ const y = event.touches[0]?.clientY ?? 0;
590
+ if (y > lastTouchYRef.current)
591
+ releaseFollow();
592
+ lastTouchYRef.current = y;
593
+ }, [releaseFollow]);
594
+ // Keys scroll the viewport only while it is the focused element; bubbled
595
+ // keydowns from interactive children never move it, so they must not release.
596
+ const handleKeyDown = useCallback((event) => {
597
+ if (event.target !== event.currentTarget)
598
+ return;
599
+ const scrollsUp = USER_SCROLL_UP_KEYS.has(event.key) || (event.key === " " && event.shiftKey);
600
+ if (scrollsUp)
601
+ releaseFollow();
602
+ }, [releaseFollow]);
603
+ return useRenderElement("div", { className, render, style }, {
604
+ ref: scrollRef,
605
+ props: [
606
+ {
607
+ "data-thread-scroller": "",
608
+ role: "region",
609
+ "aria-label": "Messages",
610
+ tabIndex: 0,
611
+ style: { overflowY: "auto", overflowX: "hidden" },
612
+ onWheel: handleWheel,
613
+ onTouchStart: handleTouchStart,
614
+ onTouchMove: handleTouchMove,
615
+ onKeyDown: handleKeyDown,
616
+ },
617
+ elementProps,
618
+ ],
619
+ });
620
+ };
621
+ export const ThreadContent = ({ className, render, style, ...elementProps }) => {
622
+ const { contentRef, topSentinelRef, bottomSentinelRef } = useThreadContext();
623
+ const element = useRenderElement("div", { className, render, style }, {
624
+ ref: contentRef,
625
+ props: [
626
+ {
627
+ "data-thread-content": "",
628
+ // The transcript is the live log; new turns are what screen readers
629
+ // should announce.
630
+ role: "log",
631
+ "aria-relevant": "additions",
632
+ },
633
+ elementProps,
634
+ ],
635
+ });
636
+ return (_jsxs(_Fragment, { children: [_jsx("div", { ref: topSentinelRef, "data-thread-top": "", "aria-hidden": true, style: { height: 1, width: "100%", flexShrink: 0 } }), element, _jsx("div", { ref: bottomSentinelRef, "data-thread-bottom": "", "aria-hidden": true, style: { height: 1, width: "100%", flexShrink: 0 } })] }));
637
+ };
638
+ export const ThreadComposer = ({ className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, { props: [{ "data-thread-composer": "" }, elementProps] });
639
+ export const ThreadPlaceholder = ({ className, render, style, ...elementProps }) => useRenderElement("div", { className, render, style }, { props: [{ "data-thread-placeholder": "" }, elementProps] });