@townco/ui 0.1.72 → 0.1.74

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.
@@ -0,0 +1,97 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { AlertCircle, AlertTriangle, Archive, CheckCircle2, ChevronDown, Loader2, Scissors, } from "lucide-react";
3
+ import React, { useEffect, useState } from "react";
4
+ /**
5
+ * Get display information for a hook type
6
+ */
7
+ function getHookDisplayInfo(hookType, _callback, status) {
8
+ const isTriggered = status === "triggered";
9
+ if (hookType === "context_size") {
10
+ return {
11
+ icon: Archive,
12
+ title: isTriggered ? "Compacting Context..." : "Context Compacted",
13
+ };
14
+ }
15
+ if (hookType === "tool_response") {
16
+ return {
17
+ icon: Scissors,
18
+ title: isTriggered ? "Compacting Response..." : "Tool Response Compacted",
19
+ };
20
+ }
21
+ // Fallback for unknown hook types
22
+ return {
23
+ icon: Archive,
24
+ title: isTriggered ? "Running Hook..." : "Hook Executed",
25
+ };
26
+ }
27
+ /**
28
+ * Format a number with thousand separators
29
+ */
30
+ function formatNumber(num) {
31
+ return num.toLocaleString();
32
+ }
33
+ /**
34
+ * HookNotification component - displays a hook notification inline with messages
35
+ * Shows triggered (loading), completed, or error states
36
+ */
37
+ export function HookNotification({ notification }) {
38
+ const [isExpanded, setIsExpanded] = useState(false);
39
+ const [elapsedTime, setElapsedTime] = useState(0);
40
+ const { icon: IconComponent, title } = getHookDisplayInfo(notification.hookType, notification.callback, notification.status);
41
+ const isTriggered = notification.status === "triggered";
42
+ const isCompleted = notification.status === "completed";
43
+ const isError = notification.status === "error";
44
+ // Update elapsed time while hook is running
45
+ useEffect(() => {
46
+ if (!isTriggered || !notification.triggeredAt) {
47
+ return;
48
+ }
49
+ // Calculate initial elapsed time
50
+ const updateElapsed = () => {
51
+ const elapsed = (Date.now() - notification.triggeredAt) / 1000;
52
+ setElapsedTime(elapsed);
53
+ };
54
+ // Update immediately
55
+ updateElapsed();
56
+ // Update every 100ms for smooth display
57
+ const interval = setInterval(updateElapsed, 100);
58
+ return () => clearInterval(interval);
59
+ }, [isTriggered, notification.triggeredAt]);
60
+ // Build subtitle showing key info
61
+ let subtitle = "";
62
+ if (isTriggered && notification.currentPercentage !== undefined) {
63
+ subtitle = `Context at ${notification.currentPercentage.toFixed(1)}% (threshold: ${notification.threshold}%)`;
64
+ }
65
+ else if (isCompleted && notification.metadata?.tokensSaved !== undefined) {
66
+ const { tokensSaved, originalTokens } = notification.metadata;
67
+ // For tool_response hooks - show percentage reduced of the tool response
68
+ if (originalTokens !== undefined && originalTokens > 0) {
69
+ const percentageSaved = ((tokensSaved ?? 0) / originalTokens) * 100;
70
+ subtitle = `${percentageSaved.toFixed(0)}% reduced`;
71
+ }
72
+ // For context_size hooks - calculate % of overall context saved
73
+ // We have currentPercentage (e.g., 81.3%) from triggered notification
74
+ // tokensSaved / (currentPercentage% of 200k) = % of context saved
75
+ else if (notification.currentPercentage !== undefined &&
76
+ notification.currentPercentage > 0) {
77
+ // currentPercentage is the % of context used before compaction
78
+ // So the total tokens before = (currentPercentage / 100) * modelContextWindow
79
+ // We default to 200k if not specified
80
+ const modelContextWindow = 200000;
81
+ const totalTokensBefore = (notification.currentPercentage / 100) * modelContextWindow;
82
+ const percentageOfContextSaved = ((tokensSaved ?? 0) / totalTokensBefore) * 100;
83
+ subtitle = `${percentageOfContextSaved.toFixed(1)}% context freed`;
84
+ }
85
+ // Fallback to absolute tokens
86
+ else {
87
+ subtitle = `${formatNumber(tokensSaved)} tokens saved`;
88
+ }
89
+ }
90
+ else if (isError && notification.error) {
91
+ subtitle = notification.error;
92
+ }
93
+ // Check for truncation warning in metadata
94
+ const truncationWarning = notification.metadata?.truncationWarning;
95
+ return (_jsxs("div", { className: "flex flex-col my-3", children: [_jsxs("button", { type: "button", className: "flex flex-col items-start gap-0.5 cursor-pointer bg-transparent border-none p-0 text-left group w-fit", onClick: () => setIsExpanded(!isExpanded), "aria-expanded": isExpanded, children: [_jsxs("div", { className: "flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground", children: [_jsx("div", { className: isError ? "text-destructive" : "text-muted-foreground", children: _jsx(IconComponent, { className: "h-3 w-3" }) }), _jsx("span", { className: "text-paragraph-sm text-muted-foreground", children: title }), isTriggered && (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-3 w-3 text-muted-foreground animate-spin" }), _jsxs("span", { className: "text-muted-foreground/70 tabular-nums", children: [elapsedTime.toFixed(1), "s"] })] })), isCompleted && _jsx(CheckCircle2, { className: "h-3 w-3 text-green-500" }), isError && _jsx(AlertCircle, { className: "h-3 w-3 text-destructive" }), !isTriggered && (_jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}` }))] }), subtitle && (_jsx("span", { className: `text-paragraph-sm pl-4.5 ${isError ? "text-destructive/70" : "text-muted-foreground/70"}`, children: subtitle }))] }), isExpanded && !isTriggered && (_jsxs("div", { className: "mt-2 text-sm border border-border rounded-lg bg-card overflow-hidden w-full", children: [_jsxs("div", { className: "p-3 border-b border-border", children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Hook Details" }), _jsxs("div", { className: "space-y-1 text-[11px]", children: [_jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Type:" }), _jsx("span", { className: "text-foreground font-mono", children: notification.hookType })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Callback:" }), _jsx("span", { className: "text-foreground font-mono", children: notification.callback })] }), notification.threshold !== undefined && (_jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Threshold:" }), _jsxs("span", { className: "text-foreground", children: [notification.threshold, "%"] })] })), notification.currentPercentage !== undefined && (_jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Context Usage:" }), _jsxs("span", { className: "text-foreground", children: [notification.currentPercentage.toFixed(1), "%"] })] }))] })] }), notification.metadata && (_jsxs("div", { className: "p-3 border-b border-border last:border-0", children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Result" }), _jsxs("div", { className: "space-y-1 text-[11px]", children: [notification.metadata.action && (_jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Action:" }), _jsx("span", { className: "text-foreground", children: notification.metadata.action })] })), notification.metadata.messagesRemoved !== undefined && (_jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Messages Removed:" }), _jsx("span", { className: "text-foreground", children: formatNumber(notification.metadata.messagesRemoved) })] })), notification.metadata.tokensSaved !== undefined && (_jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Tokens Saved:" }), _jsx("span", { className: "text-green-500 font-medium", children: formatNumber(notification.metadata.tokensSaved) })] })), notification.metadata.originalTokens !== undefined && (_jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Original Size:" }), _jsxs("span", { className: "text-foreground", children: [formatNumber(notification.metadata.originalTokens), " ", "tokens"] })] })), notification.metadata.finalTokens !== undefined && (_jsxs("div", { className: "flex gap-2", children: [_jsx("span", { className: "text-muted-foreground", children: "Final Size:" }), _jsxs("span", { className: "text-foreground", children: [formatNumber(notification.metadata.finalTokens), " tokens"] })] }))] })] })), truncationWarning && (_jsxs("div", { className: "p-3 border-b border-border last:border-0 bg-yellow-500/5", children: [_jsxs("div", { className: "flex items-center gap-1.5 text-[10px] font-bold text-yellow-600 dark:text-yellow-500 uppercase tracking-wider mb-1.5 font-sans", children: [_jsx(AlertTriangle, { className: "h-3 w-3" }), "Warning"] }), _jsx("div", { className: "text-[11px] text-yellow-700 dark:text-yellow-400", children: truncationWarning })] })), notification.error && (_jsxs("div", { className: "p-3 border-b border-border last:border-0", children: [_jsx("div", { className: "text-[10px] font-bold text-destructive uppercase tracking-wider mb-1.5 font-sans", children: "Error" }), _jsx("div", { className: "text-[11px] text-destructive font-mono", children: notification.error })] })), _jsxs("div", { className: "p-2 bg-muted/50 border-t border-border text-[10px] text-muted-foreground font-sans space-y-0.5", children: [notification.triggeredAt && (_jsxs("div", { children: ["Started:", " ", new Date(notification.triggeredAt).toLocaleTimeString()] })), notification.completedAt && (_jsxs("div", { children: ["Completed:", " ", new Date(notification.completedAt).toLocaleTimeString()] })), notification.triggeredAt && notification.completedAt && (_jsxs("div", { children: ["Duration:", " ", ((notification.completedAt - notification.triggeredAt) /
96
+ 1000).toFixed(1), "s"] }))] })] }))] }));
97
+ }
@@ -6,6 +6,7 @@ import { useChatStore } from "../../core/store/chat-store.js";
6
6
  import { isPreliminaryToolCall } from "../../core/utils/tool-call-state.js";
7
7
  import { getDuration, getTransition, motionEasing, shimmerTransition, } from "../lib/motion.js";
8
8
  import { cn } from "../lib/utils.js";
9
+ import { HookNotification } from "./HookNotification.js";
9
10
  import { Reasoning } from "./Reasoning.js";
10
11
  import { Response } from "./Response.js";
11
12
  import { ToolOperation } from "./ToolOperation.js";
@@ -185,11 +186,72 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
185
186
  // Single tool call
186
187
  return (_jsx(ToolOperation, { toolCalls: [item], isGrouped: false }, item.id));
187
188
  };
189
+ // Get hook notifications and sort by content position
190
+ const hookNotifications = (message.hookNotifications || [])
191
+ .slice()
192
+ .sort((a, b) => (a.contentPosition ?? Infinity) -
193
+ (b.contentPosition ?? Infinity));
188
194
  // If no tool calls or they don't have positions, render simplified way
189
195
  if (sortedToolCalls.length === 0 ||
190
196
  !sortedToolCalls.some((tc) => tc.contentPosition !== undefined)) {
191
197
  const groupedToolCalls = groupToolCalls(sortedToolCalls);
192
- return (_jsxs(_Fragment, { children: [groupedToolCalls.length > 0 && (_jsx(AnimatePresence, { mode: "popLayout", children: _jsx("div", { className: "flex flex-col gap-2 mb-1", children: groupedToolCalls.map((item, index) => renderToolCallOrGroup(item, index)) }) })), _jsx(motion.div, { initial: {
198
+ // Check if hook notifications have positions for inline rendering
199
+ const hasHookPositions = hookNotifications.some((n) => n.contentPosition !== undefined);
200
+ if (!hasHookPositions) {
201
+ // No positions - render hooks at top, then tool calls, then content
202
+ return (_jsxs(_Fragment, { children: [hookNotifications.length > 0 && (_jsx("div", { className: "flex flex-col gap-2 mb-1", children: hookNotifications.map((notification) => (_jsx(HookNotification, { notification: notification }, notification.id))) })), groupedToolCalls.length > 0 && (_jsx(AnimatePresence, { mode: "popLayout", children: _jsx("div", { className: "flex flex-col gap-2 mb-1", children: groupedToolCalls.map((item, index) => renderToolCallOrGroup(item, index)) }) })), _jsx(motion.div, { initial: {
203
+ filter: "blur(12px)",
204
+ opacity: 0,
205
+ y: 12,
206
+ }, animate: {
207
+ filter: "blur(0px)",
208
+ opacity: 1,
209
+ y: 0,
210
+ }, exit: {
211
+ filter: "blur(12px)",
212
+ opacity: 0,
213
+ y: -12,
214
+ }, transition: getTransition(shouldReduceMotion ?? false, {
215
+ duration: 0.4,
216
+ ease: motionEasing.smooth,
217
+ }), children: _jsx(Response, { content: message.content, isStreaming: message.isStreaming, showEmpty: false }) })] }));
218
+ }
219
+ // Hooks have positions - render them inline with content
220
+ const elements = [];
221
+ let currentPosition = 0;
222
+ hookNotifications.forEach((notification) => {
223
+ const position = notification.contentPosition ?? message.content.length;
224
+ // Add text before this hook notification
225
+ if (position > currentPosition) {
226
+ const textChunk = message.content.slice(currentPosition, position);
227
+ if (textChunk) {
228
+ elements.push(_jsx(motion.div, { initial: {
229
+ filter: "blur(12px)",
230
+ opacity: 0,
231
+ y: 12,
232
+ }, animate: {
233
+ filter: "blur(0px)",
234
+ opacity: 1,
235
+ y: 0,
236
+ }, exit: {
237
+ filter: "blur(12px)",
238
+ opacity: 0,
239
+ y: -12,
240
+ }, transition: getTransition(shouldReduceMotion ?? false, {
241
+ duration: 0.4,
242
+ ease: motionEasing.smooth,
243
+ }), children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false }) }, `text-before-hook-${notification.id}`));
244
+ }
245
+ }
246
+ // Add hook notification
247
+ elements.push(_jsx(HookNotification, { notification: notification }, notification.id));
248
+ currentPosition = position;
249
+ });
250
+ // Add remaining text
251
+ if (currentPosition < message.content.length) {
252
+ const remainingText = message.content.slice(currentPosition);
253
+ if (remainingText) {
254
+ elements.push(_jsx(motion.div, { initial: {
193
255
  filter: "blur(12px)",
194
256
  opacity: 0,
195
257
  y: 12,
@@ -204,11 +266,41 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
204
266
  }, transition: getTransition(shouldReduceMotion ?? false, {
205
267
  duration: 0.4,
206
268
  ease: motionEasing.smooth,
207
- }), children: _jsx(Response, { content: message.content, isStreaming: message.isStreaming, showEmpty: false }) })] }));
269
+ }), children: _jsx(Response, { content: remainingText, isStreaming: message.isStreaming, showEmpty: false }) }, "text-end-hooks"));
270
+ }
271
+ }
272
+ // Add tool calls at the end
273
+ if (groupedToolCalls.length > 0) {
274
+ groupedToolCalls.forEach((item, index) => {
275
+ elements.push(renderToolCallOrGroup(item, index));
276
+ });
277
+ }
278
+ return (_jsx(AnimatePresence, { mode: "popLayout", children: elements }));
208
279
  }
209
- // Render content interleaved with tool calls
280
+ // Render content interleaved with tool calls and hook notifications
210
281
  // Group consecutive tool calls with the same batchId or same title
211
282
  const elements = [];
283
+ const positionedItems = [];
284
+ // Add non-preliminary tool calls with positions
285
+ const preliminaryToolCalls = sortedToolCalls.filter(isPreliminaryToolCall);
286
+ const nonPreliminaryToolCalls = sortedToolCalls.filter((tc) => !isPreliminaryToolCall(tc));
287
+ nonPreliminaryToolCalls.forEach((tc) => {
288
+ positionedItems.push({
289
+ type: "toolCall",
290
+ item: tc,
291
+ position: tc.contentPosition ?? message.content.length,
292
+ });
293
+ });
294
+ // Add hook notifications with positions
295
+ hookNotifications.forEach((n) => {
296
+ positionedItems.push({
297
+ type: "hookNotification",
298
+ item: n,
299
+ position: n.contentPosition ?? 0, // Default to start if no position
300
+ });
301
+ });
302
+ // Sort by position
303
+ positionedItems.sort((a, b) => a.position - b.position);
212
304
  let currentPosition = 0;
213
305
  let currentBatch = [];
214
306
  let currentBatchId;
@@ -225,18 +317,18 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
225
317
  currentBatchId = undefined;
226
318
  currentBatchTitle = undefined;
227
319
  };
228
- // Separate preliminary tool calls - they should render at the end, not break text
229
- const preliminaryToolCalls = sortedToolCalls.filter(isPreliminaryToolCall);
230
- const nonPreliminaryToolCalls = sortedToolCalls.filter((tc) => !isPreliminaryToolCall(tc));
231
- // Process non-preliminary tool calls inline with text
232
- nonPreliminaryToolCalls.forEach((toolCall, index) => {
233
- const position = toolCall.contentPosition ?? message.content.length;
234
- // Add text before this tool call
320
+ // Process positioned items (tool calls and hook notifications) inline with text
321
+ positionedItems.forEach((positionedItem, index) => {
322
+ const position = positionedItem.position;
323
+ // Add text before this item
235
324
  if (position > currentPosition) {
236
325
  // Flush any pending batch before adding text
237
326
  flushBatch();
238
327
  const textChunk = message.content.slice(currentPosition, position);
239
328
  if (textChunk) {
329
+ const itemId = positionedItem.type === "toolCall"
330
+ ? positionedItem.item.id
331
+ : positionedItem.item.id;
240
332
  elements.push(_jsx(motion.div, { initial: {
241
333
  filter: "blur(12px)",
242
334
  opacity: 0,
@@ -252,37 +344,46 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
252
344
  }, transition: getTransition(shouldReduceMotion ?? false, {
253
345
  duration: 0.4,
254
346
  ease: motionEasing.smooth,
255
- }), children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false }) }, `text-before-${toolCall.id}`));
347
+ }), children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false }) }, `text-before-${itemId}`));
256
348
  }
257
349
  }
258
- // Check if this tool call should be batched (by batchId or consecutive same title)
259
- if (toolCall.batchId) {
260
- if (currentBatchId === toolCall.batchId) {
261
- // Same batch, add to current batch
350
+ if (positionedItem.type === "hookNotification") {
351
+ // Flush any pending tool call batch before adding hook
352
+ flushBatch();
353
+ // Add hook notification
354
+ elements.push(_jsx(HookNotification, { notification: positionedItem.item }, positionedItem.item.id));
355
+ }
356
+ else {
357
+ // Tool call - check if it should be batched
358
+ const toolCall = positionedItem.item;
359
+ if (toolCall.batchId) {
360
+ if (currentBatchId === toolCall.batchId) {
361
+ // Same batch, add to current batch
362
+ currentBatch.push(toolCall);
363
+ }
364
+ else {
365
+ // Different batch, flush previous and start new
366
+ flushBatch();
367
+ currentBatchId = toolCall.batchId;
368
+ currentBatchTitle = toolCall.title;
369
+ currentBatch = [toolCall];
370
+ }
371
+ }
372
+ else if (currentBatchTitle === toolCall.title &&
373
+ !currentBatchId) {
374
+ // Same title as previous (no batchId), continue grouping
262
375
  currentBatch.push(toolCall);
263
376
  }
264
377
  else {
265
- // Different batch, flush previous and start new
378
+ // Different title or switching from batchId to title grouping
266
379
  flushBatch();
267
- currentBatchId = toolCall.batchId;
268
380
  currentBatchTitle = toolCall.title;
269
381
  currentBatch = [toolCall];
270
382
  }
271
383
  }
272
- else if (currentBatchTitle === toolCall.title &&
273
- !currentBatchId) {
274
- // Same title as previous (no batchId), continue grouping
275
- currentBatch.push(toolCall);
276
- }
277
- else {
278
- // Different title or switching from batchId to title grouping
279
- flushBatch();
280
- currentBatchTitle = toolCall.title;
281
- currentBatch = [toolCall];
282
- }
283
384
  currentPosition = position;
284
385
  // Flush batch at the end
285
- if (index === nonPreliminaryToolCalls.length - 1) {
386
+ if (index === positionedItems.length - 1) {
286
387
  flushBatch();
287
388
  }
288
389
  });
@@ -22,6 +22,7 @@ export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe
22
22
  export { FileSystemItem, type FileSystemItemProps, } from "./FileSystemItem.js";
23
23
  export { FileSystemView, type FileSystemViewProps, } from "./FileSystemView.js";
24
24
  export { HeightTransition } from "./HeightTransition.js";
25
+ export { HookNotification, type HookNotificationProps, } from "./HookNotification.js";
25
26
  export { IconButton, type IconButtonProps } from "./IconButton.js";
26
27
  export { Input, type InputProps, inputVariants } from "./Input.js";
27
28
  export { Label } from "./Label.js";
@@ -29,6 +29,7 @@ export { FileSystemItem, } from "./FileSystemItem.js";
29
29
  export { FileSystemView, } from "./FileSystemView.js";
30
30
  // Utility components
31
31
  export { HeightTransition } from "./HeightTransition.js";
32
+ export { HookNotification, } from "./HookNotification.js";
32
33
  export { IconButton } from "./IconButton.js";
33
34
  export { Input, inputVariants } from "./Input.js";
34
35
  export { Label } from "./Label.js";
@@ -1 +1,2 @@
1
1
  export { useIsMobile } from "./use-mobile.js";
2
+ export { useScrollToBottom } from "./use-scroll-to-bottom.js";
@@ -1 +1,2 @@
1
1
  export { useIsMobile } from "./use-mobile.js";
2
+ export { useScrollToBottom } from "./use-scroll-to-bottom.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Hook for managing auto-scrolling behavior in a chat container.
3
+ * Inspired by shadcn.io/ai-chatbot implementation.
4
+ *
5
+ * Key features:
6
+ * - Tracks user scroll position and whether they're actively scrolling
7
+ * - Auto-scrolls to bottom when content changes (streaming) if user was at bottom
8
+ * - Respects user scrolling - won't auto-scroll if user is manually scrolling
9
+ * - Uses MutationObserver for DOM changes and ResizeObserver for size changes
10
+ */
11
+ export declare function useScrollToBottom(): {
12
+ containerRef: import("react").RefObject<HTMLDivElement | null>;
13
+ endRef: import("react").RefObject<HTMLDivElement | null>;
14
+ isAtBottom: boolean;
15
+ scrollToBottom: (behavior?: ScrollBehavior) => void;
16
+ onViewportEnter: () => void;
17
+ onViewportLeave: () => void;
18
+ };
@@ -0,0 +1,120 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ /**
3
+ * Hook for managing auto-scrolling behavior in a chat container.
4
+ * Inspired by shadcn.io/ai-chatbot implementation.
5
+ *
6
+ * Key features:
7
+ * - Tracks user scroll position and whether they're actively scrolling
8
+ * - Auto-scrolls to bottom when content changes (streaming) if user was at bottom
9
+ * - Respects user scrolling - won't auto-scroll if user is manually scrolling
10
+ * - Uses MutationObserver for DOM changes and ResizeObserver for size changes
11
+ */
12
+ export function useScrollToBottom() {
13
+ const containerRef = useRef(null);
14
+ const endRef = useRef(null);
15
+ const [isAtBottom, setIsAtBottom] = useState(true);
16
+ const isAtBottomRef = useRef(true);
17
+ const isUserScrollingRef = useRef(false);
18
+ // Keep ref in sync with state
19
+ useEffect(() => {
20
+ isAtBottomRef.current = isAtBottom;
21
+ }, [isAtBottom]);
22
+ const checkIfAtBottom = useCallback(() => {
23
+ if (!containerRef.current) {
24
+ return true;
25
+ }
26
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
27
+ // Consider "at bottom" if within 100px of the bottom
28
+ return scrollTop + clientHeight >= scrollHeight - 100;
29
+ }, []);
30
+ const scrollToBottom = useCallback((behavior = "smooth") => {
31
+ if (!containerRef.current) {
32
+ return;
33
+ }
34
+ containerRef.current.scrollTo({
35
+ top: containerRef.current.scrollHeight,
36
+ behavior,
37
+ });
38
+ }, []);
39
+ // Handle user scroll events
40
+ useEffect(() => {
41
+ const container = containerRef.current;
42
+ if (!container) {
43
+ return;
44
+ }
45
+ let scrollTimeout;
46
+ const handleScroll = () => {
47
+ // Mark as user scrolling
48
+ isUserScrollingRef.current = true;
49
+ clearTimeout(scrollTimeout);
50
+ // Update isAtBottom state
51
+ const atBottom = checkIfAtBottom();
52
+ setIsAtBottom(atBottom);
53
+ isAtBottomRef.current = atBottom;
54
+ // Reset user scrolling flag after scroll ends (150ms debounce)
55
+ scrollTimeout = setTimeout(() => {
56
+ isUserScrollingRef.current = false;
57
+ }, 150);
58
+ };
59
+ container.addEventListener("scroll", handleScroll, { passive: true });
60
+ return () => {
61
+ container.removeEventListener("scroll", handleScroll);
62
+ clearTimeout(scrollTimeout);
63
+ };
64
+ }, [checkIfAtBottom]);
65
+ // Auto-scroll when content changes
66
+ useEffect(() => {
67
+ const container = containerRef.current;
68
+ if (!container) {
69
+ return;
70
+ }
71
+ const scrollIfNeeded = () => {
72
+ // Only auto-scroll if user was at bottom and isn't actively scrolling
73
+ if (isAtBottomRef.current && !isUserScrollingRef.current) {
74
+ requestAnimationFrame(() => {
75
+ container.scrollTo({
76
+ top: container.scrollHeight,
77
+ behavior: "instant",
78
+ });
79
+ setIsAtBottom(true);
80
+ isAtBottomRef.current = true;
81
+ });
82
+ }
83
+ };
84
+ // Watch for DOM changes (content streaming)
85
+ const mutationObserver = new MutationObserver(scrollIfNeeded);
86
+ mutationObserver.observe(container, {
87
+ childList: true,
88
+ subtree: true,
89
+ characterData: true,
90
+ });
91
+ // Watch for size changes
92
+ const resizeObserver = new ResizeObserver(scrollIfNeeded);
93
+ resizeObserver.observe(container);
94
+ // Also observe children for size changes
95
+ Array.from(container.children).forEach((child) => {
96
+ resizeObserver.observe(child);
97
+ });
98
+ return () => {
99
+ mutationObserver.disconnect();
100
+ resizeObserver.disconnect();
101
+ };
102
+ }, []);
103
+ // Callbacks for viewport enter/leave (if using intersection observer externally)
104
+ function onViewportEnter() {
105
+ setIsAtBottom(true);
106
+ isAtBottomRef.current = true;
107
+ }
108
+ function onViewportLeave() {
109
+ setIsAtBottom(false);
110
+ isAtBottomRef.current = false;
111
+ }
112
+ return {
113
+ containerRef,
114
+ endRef,
115
+ isAtBottom,
116
+ scrollToBottom,
117
+ onViewportEnter,
118
+ onViewportLeave,
119
+ };
120
+ }