@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/ask-user/ask-user.d.ts +104 -0
- package/dist/ask-user/ask-user.js +226 -0
- package/dist/ask-user/index.d.ts +3 -0
- package/dist/ask-user/index.js +3 -0
- package/dist/ask-user/index.parts.d.ts +1 -0
- package/dist/ask-user/index.parts.js +5 -0
- package/dist/attachments/attachments.d.ts +41 -0
- package/dist/attachments/attachments.js +91 -0
- package/dist/attachments/index.d.ts +3 -0
- package/dist/attachments/index.js +3 -0
- package/dist/attachments/index.parts.d.ts +1 -0
- package/dist/attachments/index.parts.js +5 -0
- package/dist/chip/chip.d.ts +25 -0
- package/dist/chip/chip.js +25 -0
- package/dist/chip/index.d.ts +2 -0
- package/dist/chip/index.js +2 -0
- package/dist/chip/index.parts.d.ts +1 -0
- package/dist/chip/index.parts.js +5 -0
- package/dist/chip-markdown.d.ts +25 -0
- package/dist/chip-markdown.js +52 -0
- package/dist/composer/actions.d.ts +35 -0
- package/dist/composer/actions.js +75 -0
- package/dist/composer/ask-user-machine.d.ts +52 -0
- package/dist/composer/ask-user-machine.js +164 -0
- package/dist/composer/attachments-machine.d.ts +34 -0
- package/dist/composer/attachments-machine.js +91 -0
- package/dist/composer/attachments.d.ts +16 -0
- package/dist/composer/attachments.js +32 -0
- package/dist/composer/command-list.d.ts +49 -0
- package/dist/composer/command-list.js +357 -0
- package/dist/composer/commands.d.ts +28 -0
- package/dist/composer/commands.js +56 -0
- package/dist/composer/container.d.ts +3 -0
- package/dist/composer/container.js +32 -0
- package/dist/composer/controller.d.ts +3 -0
- package/dist/composer/controller.js +6 -0
- package/dist/composer/editor-dom.d.ts +55 -0
- package/dist/composer/editor-dom.js +431 -0
- package/dist/composer/fuzzy.d.ts +4 -0
- package/dist/composer/fuzzy.js +49 -0
- package/dist/composer/index.d.ts +25 -0
- package/dist/composer/index.js +13 -0
- package/dist/composer/index.parts.d.ts +11 -0
- package/dist/composer/index.parts.js +15 -0
- package/dist/composer/internals.d.ts +29 -0
- package/dist/composer/internals.js +102 -0
- package/dist/composer/keyboard.d.ts +61 -0
- package/dist/composer/keyboard.js +75 -0
- package/dist/composer/panel.d.ts +28 -0
- package/dist/composer/panel.js +80 -0
- package/dist/composer/placeholder.d.ts +10 -0
- package/dist/composer/placeholder.js +8 -0
- package/dist/composer/popover.d.ts +12 -0
- package/dist/composer/popover.js +63 -0
- package/dist/composer/prefix-detection.d.ts +23 -0
- package/dist/composer/prefix-detection.js +58 -0
- package/dist/composer/root.d.ts +21 -0
- package/dist/composer/root.js +107 -0
- package/dist/composer/segments.d.ts +108 -0
- package/dist/composer/segments.js +366 -0
- package/dist/composer/store.d.ts +97 -0
- package/dist/composer/store.js +451 -0
- package/dist/composer/textarea.d.ts +39 -0
- package/dist/composer/textarea.js +64 -0
- package/dist/composer/trigger-tracker.d.ts +12 -0
- package/dist/composer/trigger-tracker.js +90 -0
- package/dist/composer/types.d.ts +126 -0
- package/dist/composer/types.js +4 -0
- package/dist/composer/use-composer-editor.d.ts +43 -0
- package/dist/composer/use-composer-editor.js +649 -0
- package/dist/internal/collapsible.d.ts +22 -0
- package/dist/internal/collapsible.js +90 -0
- package/dist/internal/primitive-props.d.ts +4 -0
- package/dist/internal/primitive-props.js +4 -0
- package/dist/internal/render/baseTypes.d.ts +18 -0
- package/dist/internal/render/baseTypes.js +4 -0
- package/dist/internal/render/empty.d.ts +3 -0
- package/dist/internal/render/empty.js +5 -0
- package/dist/internal/render/getReactElementRef.d.ts +5 -0
- package/dist/internal/render/getReactElementRef.js +15 -0
- package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
- package/dist/internal/render/getStateAttributesProps.js +23 -0
- package/dist/internal/render/mergeObjects.d.ts +1 -0
- package/dist/internal/render/mergeObjects.js +14 -0
- package/dist/internal/render/mergeProps.d.ts +55 -0
- package/dist/internal/render/mergeProps.js +183 -0
- package/dist/internal/render/positioning.d.ts +22 -0
- package/dist/internal/render/positioning.js +78 -0
- package/dist/internal/render/reactVersion.d.ts +3 -0
- package/dist/internal/render/reactVersion.js +7 -0
- package/dist/internal/render/resolveClassName.d.ts +8 -0
- package/dist/internal/render/resolveClassName.js +12 -0
- package/dist/internal/render/resolveStyle.d.ts +8 -0
- package/dist/internal/render/resolveStyle.js +12 -0
- package/dist/internal/render/transition.d.ts +48 -0
- package/dist/internal/render/transition.js +203 -0
- package/dist/internal/render/types.d.ts +74 -0
- package/dist/internal/render/types.js +3 -0
- package/dist/internal/render/useMergedRefs.d.ts +21 -0
- package/dist/internal/render/useMergedRefs.js +102 -0
- package/dist/internal/render/useRefWithInit.d.ts +10 -0
- package/dist/internal/render/useRefWithInit.js +12 -0
- package/dist/internal/render/useRender.d.ts +71 -0
- package/dist/internal/render/useRender.js +12 -0
- package/dist/internal/render/useRenderElement.d.ts +62 -0
- package/dist/internal/render/useRenderElement.js +157 -0
- package/dist/internal/render/warn.d.ts +1 -0
- package/dist/internal/render/warn.js +15 -0
- package/dist/internal/state-mappings.d.ts +8 -0
- package/dist/internal/state-mappings.js +17 -0
- package/dist/message/index.d.ts +3 -0
- package/dist/message/index.js +3 -0
- package/dist/message/index.parts.d.ts +1 -0
- package/dist/message/index.parts.js +5 -0
- package/dist/message/message.d.ts +43 -0
- package/dist/message/message.js +78 -0
- package/dist/message-utils.d.ts +55 -0
- package/dist/message-utils.js +79 -0
- package/dist/reasoning/index.d.ts +3 -0
- package/dist/reasoning/index.js +3 -0
- package/dist/reasoning/index.parts.d.ts +1 -0
- package/dist/reasoning/index.parts.js +5 -0
- package/dist/reasoning/reasoning.d.ts +20 -0
- package/dist/reasoning/reasoning.js +49 -0
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/dist/steps/index.parts.d.ts +1 -0
- package/dist/steps/index.parts.js +5 -0
- package/dist/steps/steps.d.ts +53 -0
- package/dist/steps/steps.js +79 -0
- package/dist/thread/geometry.d.ts +18 -0
- package/dist/thread/geometry.js +59 -0
- package/dist/thread/index.d.ts +4 -0
- package/dist/thread/index.js +3 -0
- package/dist/thread/index.parts.d.ts +1 -0
- package/dist/thread/index.parts.js +5 -0
- package/dist/thread/stores.d.ts +21 -0
- package/dist/thread/stores.js +63 -0
- package/dist/thread/thread.d.ts +66 -0
- package/dist/thread/thread.js +639 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +17 -0
- 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] });
|