@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.
- package/dist/core/hooks/use-chat-messages.d.ts +23 -0
- package/dist/core/hooks/use-chat-messages.js +8 -0
- package/dist/core/schemas/chat.d.ts +93 -0
- package/dist/core/schemas/chat.js +38 -0
- package/dist/core/store/chat-store.d.ts +3 -0
- package/dist/core/store/chat-store.js +133 -0
- package/dist/gui/components/ChatLayout.js +12 -50
- package/dist/gui/components/ChatView.js +3 -4
- package/dist/gui/components/ContextUsageButton.d.ts +1 -1
- package/dist/gui/components/ContextUsageButton.js +6 -2
- package/dist/gui/components/HookNotification.d.ts +9 -0
- package/dist/gui/components/HookNotification.js +97 -0
- package/dist/gui/components/MessageContent.js +130 -29
- package/dist/gui/components/index.d.ts +1 -0
- package/dist/gui/components/index.js +1 -0
- package/dist/gui/hooks/index.d.ts +1 -0
- package/dist/gui/hooks/index.js +1 -0
- package/dist/gui/hooks/use-scroll-to-bottom.d.ts +18 -0
- package/dist/gui/hooks/use-scroll-to-bottom.js +120 -0
- package/dist/sdk/schemas/message.d.ts +173 -2
- package/dist/sdk/schemas/message.js +60 -0
- package/dist/sdk/schemas/session.d.ts +6 -6
- package/dist/sdk/transports/http.js +28 -0
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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-${
|
|
347
|
+
}), children: _jsx(Response, { content: textChunk, isStreaming: false, showEmpty: false }) }, `text-before-${itemId}`));
|
|
256
348
|
}
|
|
257
349
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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 ===
|
|
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";
|
package/dist/gui/hooks/index.js
CHANGED
|
@@ -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
|
+
}
|