@townco/ui 0.1.73 → 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.
@@ -159,6 +159,29 @@ export declare function useChatMessages(client: AcpClient | null, startSession:
159
159
  }[] | undefined;
160
160
  subagentStreaming?: boolean | undefined;
161
161
  }[] | undefined;
162
+ hookNotifications?: {
163
+ id: string;
164
+ hookType: "context_size" | "tool_response";
165
+ callback: string;
166
+ status: "error" | "completed" | "triggered";
167
+ threshold?: number | undefined;
168
+ currentPercentage?: number | undefined;
169
+ metadata?: {
170
+ [x: string]: unknown;
171
+ action?: string | undefined;
172
+ messagesRemoved?: number | undefined;
173
+ tokensSaved?: number | undefined;
174
+ tokensBeforeCompaction?: number | undefined;
175
+ summaryTokens?: number | undefined;
176
+ originalTokens?: number | undefined;
177
+ finalTokens?: number | undefined;
178
+ truncationWarning?: string | undefined;
179
+ } | undefined;
180
+ error?: string | undefined;
181
+ triggeredAt?: number | undefined;
182
+ completedAt?: number | undefined;
183
+ contentPosition?: number | undefined;
184
+ }[] | undefined;
162
185
  tokenUsage?: {
163
186
  inputTokens?: number | undefined;
164
187
  outputTokens?: number | undefined;
@@ -19,6 +19,7 @@ export function useChatMessages(client, startSession) {
19
19
  const updateToolCall = useChatStore((state) => state.updateToolCall);
20
20
  const addToolCallToCurrentMessage = useChatStore((state) => state.addToolCallToCurrentMessage);
21
21
  const updateToolCallInCurrentMessage = useChatStore((state) => state.updateToolCallInCurrentMessage);
22
+ const addHookNotificationToCurrentMessage = useChatStore((state) => state.addHookNotificationToCurrentMessage);
22
23
  /**
23
24
  * Send a message to the agent
24
25
  */
@@ -167,6 +168,12 @@ export function useChatMessages(client, startSession) {
167
168
  // Also update in current assistant message (for inline display)
168
169
  updateToolCallInCurrentMessage(chunk.toolCallUpdate);
169
170
  }
171
+ else if (chunk.type === "hook_notification") {
172
+ // Hook notification chunk - hook lifecycle events
173
+ logger.debug("Received hook_notification chunk", { chunk });
174
+ // Add/update hook notification in current assistant message
175
+ addHookNotificationToCurrentMessage(chunk.notification);
176
+ }
170
177
  }
171
178
  // Ensure streaming state is cleared even if no explicit isComplete was received
172
179
  if (!streamCompleted) {
@@ -204,6 +211,7 @@ export function useChatMessages(client, startSession) {
204
211
  updateToolCall,
205
212
  addToolCallToCurrentMessage,
206
213
  updateToolCallInCurrentMessage,
214
+ addHookNotificationToCurrentMessage,
207
215
  ]);
208
216
  return {
209
217
  messages,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { HookNotification, HookType } from "../../sdk/schemas/message.js";
2
3
  /**
3
4
  * Chat UI state schemas
4
5
  */
@@ -10,6 +11,40 @@ export declare const DisplayImageAttachment: z.ZodObject<{
10
11
  data: z.ZodString;
11
12
  }, z.core.$strip>;
12
13
  export type DisplayImageAttachment = z.infer<typeof DisplayImageAttachment>;
14
+ /**
15
+ * Hook notification display state (tracks triggered -> completed/error lifecycle)
16
+ */
17
+ export declare const HookNotificationDisplay: z.ZodObject<{
18
+ id: z.ZodString;
19
+ hookType: z.ZodEnum<{
20
+ context_size: "context_size";
21
+ tool_response: "tool_response";
22
+ }>;
23
+ callback: z.ZodString;
24
+ status: z.ZodEnum<{
25
+ error: "error";
26
+ completed: "completed";
27
+ triggered: "triggered";
28
+ }>;
29
+ threshold: z.ZodOptional<z.ZodNumber>;
30
+ currentPercentage: z.ZodOptional<z.ZodNumber>;
31
+ metadata: z.ZodOptional<z.ZodObject<{
32
+ action: z.ZodOptional<z.ZodString>;
33
+ messagesRemoved: z.ZodOptional<z.ZodNumber>;
34
+ tokensSaved: z.ZodOptional<z.ZodNumber>;
35
+ tokensBeforeCompaction: z.ZodOptional<z.ZodNumber>;
36
+ summaryTokens: z.ZodOptional<z.ZodNumber>;
37
+ originalTokens: z.ZodOptional<z.ZodNumber>;
38
+ finalTokens: z.ZodOptional<z.ZodNumber>;
39
+ truncationWarning: z.ZodOptional<z.ZodString>;
40
+ }, z.core.$loose>>;
41
+ error: z.ZodOptional<z.ZodString>;
42
+ triggeredAt: z.ZodOptional<z.ZodNumber>;
43
+ completedAt: z.ZodOptional<z.ZodNumber>;
44
+ contentPosition: z.ZodOptional<z.ZodNumber>;
45
+ }, z.core.$strip>;
46
+ export type HookNotificationDisplay = z.infer<typeof HookNotificationDisplay>;
47
+ export { HookType, HookNotification };
13
48
  /**
14
49
  * Display message schema (UI representation of messages)
15
50
  */
@@ -202,6 +237,35 @@ export declare const DisplayMessage: z.ZodObject<{
202
237
  }, z.core.$strip>>>;
203
238
  subagentStreaming: z.ZodOptional<z.ZodBoolean>;
204
239
  }, z.core.$strip>>>;
240
+ hookNotifications: z.ZodOptional<z.ZodArray<z.ZodObject<{
241
+ id: z.ZodString;
242
+ hookType: z.ZodEnum<{
243
+ context_size: "context_size";
244
+ tool_response: "tool_response";
245
+ }>;
246
+ callback: z.ZodString;
247
+ status: z.ZodEnum<{
248
+ error: "error";
249
+ completed: "completed";
250
+ triggered: "triggered";
251
+ }>;
252
+ threshold: z.ZodOptional<z.ZodNumber>;
253
+ currentPercentage: z.ZodOptional<z.ZodNumber>;
254
+ metadata: z.ZodOptional<z.ZodObject<{
255
+ action: z.ZodOptional<z.ZodString>;
256
+ messagesRemoved: z.ZodOptional<z.ZodNumber>;
257
+ tokensSaved: z.ZodOptional<z.ZodNumber>;
258
+ tokensBeforeCompaction: z.ZodOptional<z.ZodNumber>;
259
+ summaryTokens: z.ZodOptional<z.ZodNumber>;
260
+ originalTokens: z.ZodOptional<z.ZodNumber>;
261
+ finalTokens: z.ZodOptional<z.ZodNumber>;
262
+ truncationWarning: z.ZodOptional<z.ZodString>;
263
+ }, z.core.$loose>>;
264
+ error: z.ZodOptional<z.ZodString>;
265
+ triggeredAt: z.ZodOptional<z.ZodNumber>;
266
+ completedAt: z.ZodOptional<z.ZodNumber>;
267
+ contentPosition: z.ZodOptional<z.ZodNumber>;
268
+ }, z.core.$strip>>>;
205
269
  tokenUsage: z.ZodOptional<z.ZodObject<{
206
270
  inputTokens: z.ZodOptional<z.ZodNumber>;
207
271
  outputTokens: z.ZodOptional<z.ZodNumber>;
@@ -424,6 +488,35 @@ export declare const ChatSessionState: z.ZodObject<{
424
488
  }, z.core.$strip>>>;
425
489
  subagentStreaming: z.ZodOptional<z.ZodBoolean>;
426
490
  }, z.core.$strip>>>;
491
+ hookNotifications: z.ZodOptional<z.ZodArray<z.ZodObject<{
492
+ id: z.ZodString;
493
+ hookType: z.ZodEnum<{
494
+ context_size: "context_size";
495
+ tool_response: "tool_response";
496
+ }>;
497
+ callback: z.ZodString;
498
+ status: z.ZodEnum<{
499
+ error: "error";
500
+ completed: "completed";
501
+ triggered: "triggered";
502
+ }>;
503
+ threshold: z.ZodOptional<z.ZodNumber>;
504
+ currentPercentage: z.ZodOptional<z.ZodNumber>;
505
+ metadata: z.ZodOptional<z.ZodObject<{
506
+ action: z.ZodOptional<z.ZodString>;
507
+ messagesRemoved: z.ZodOptional<z.ZodNumber>;
508
+ tokensSaved: z.ZodOptional<z.ZodNumber>;
509
+ tokensBeforeCompaction: z.ZodOptional<z.ZodNumber>;
510
+ summaryTokens: z.ZodOptional<z.ZodNumber>;
511
+ originalTokens: z.ZodOptional<z.ZodNumber>;
512
+ finalTokens: z.ZodOptional<z.ZodNumber>;
513
+ truncationWarning: z.ZodOptional<z.ZodString>;
514
+ }, z.core.$loose>>;
515
+ error: z.ZodOptional<z.ZodString>;
516
+ triggeredAt: z.ZodOptional<z.ZodNumber>;
517
+ completedAt: z.ZodOptional<z.ZodNumber>;
518
+ contentPosition: z.ZodOptional<z.ZodNumber>;
519
+ }, z.core.$strip>>>;
427
520
  tokenUsage: z.ZodOptional<z.ZodObject<{
428
521
  inputTokens: z.ZodOptional<z.ZodNumber>;
429
522
  outputTokens: z.ZodOptional<z.ZodNumber>;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { HookNotification, HookType } from "../../sdk/schemas/message.js";
2
3
  import { TokenUsageSchema, ToolCallSchema } from "./tool-call.js";
3
4
  /**
4
5
  * Chat UI state schemas
@@ -10,6 +11,42 @@ export const DisplayImageAttachment = z.object({
10
11
  mimeType: z.string(),
11
12
  data: z.string(), // base64 encoded
12
13
  });
14
+ /**
15
+ * Hook notification display state (tracks triggered -> completed/error lifecycle)
16
+ */
17
+ export const HookNotificationDisplay = z.object({
18
+ id: z.string(),
19
+ hookType: HookType,
20
+ callback: z.string(),
21
+ status: z.enum(["triggered", "completed", "error"]),
22
+ // From triggered notification
23
+ threshold: z.number().optional(),
24
+ currentPercentage: z.number().optional(),
25
+ // From completed notification
26
+ metadata: z
27
+ .object({
28
+ action: z.string().optional(),
29
+ messagesRemoved: z.number().optional(),
30
+ tokensSaved: z.number().optional(),
31
+ // Context compaction metadata
32
+ tokensBeforeCompaction: z.number().optional(),
33
+ summaryTokens: z.number().optional(),
34
+ // Tool response compaction metadata
35
+ originalTokens: z.number().optional(),
36
+ finalTokens: z.number().optional(),
37
+ truncationWarning: z.string().optional(),
38
+ })
39
+ .passthrough()
40
+ .optional(),
41
+ // From error notification
42
+ error: z.string().optional(),
43
+ // Timestamps
44
+ triggeredAt: z.number().optional(),
45
+ completedAt: z.number().optional(),
46
+ // Position in content where the hook was triggered (for inline rendering)
47
+ contentPosition: z.number().optional(),
48
+ });
49
+ export { HookType, HookNotification };
13
50
  /**
14
51
  * Display message schema (UI representation of messages)
15
52
  */
@@ -22,6 +59,7 @@ export const DisplayMessage = z.object({
22
59
  streamingStartTime: z.number().optional(), // Unix timestamp when streaming started
23
60
  metadata: z.record(z.string(), z.unknown()).optional(),
24
61
  toolCalls: z.array(ToolCallSchema).optional(),
62
+ hookNotifications: z.array(HookNotificationDisplay).optional(), // Hook notifications for this message
25
63
  tokenUsage: TokenUsageSchema.optional(), // Token usage for this message
26
64
  images: z.array(DisplayImageAttachment).optional(), // Image attachments for user messages
27
65
  });
@@ -1,5 +1,6 @@
1
1
  import { type LogEntry } from "@townco/core";
2
2
  import type { TodoItem } from "../../gui/components/TodoListItem.js";
3
+ import type { HookNotification } from "../../sdk/schemas/message.js";
3
4
  import type { ConnectionStatus, DisplayMessage, InputState } from "../schemas/index.js";
4
5
  import type { ToolCall, ToolCallUpdate } from "../schemas/tool-call.js";
5
6
  /**
@@ -19,6 +20,7 @@ export interface ContextSize {
19
20
  toolResultsTokens: number;
20
21
  totalEstimated: number;
21
22
  llmReportedInputTokens?: number;
23
+ modelContextWindow?: number;
22
24
  }
23
25
  /**
24
26
  * Chat store state
@@ -59,6 +61,7 @@ export interface ChatStore {
59
61
  updateToolCall: (sessionId: string, update: ToolCallUpdate) => void;
60
62
  addToolCallToCurrentMessage: (toolCall: ToolCall) => void;
61
63
  updateToolCallInCurrentMessage: (update: ToolCallUpdate) => void;
64
+ addHookNotificationToCurrentMessage: (notification: HookNotification) => void;
62
65
  setInputValue: (value: string) => void;
63
66
  setInputSubmitting: (submitting: boolean) => void;
64
67
  addFileAttachment: (file: InputState["attachedFiles"][number]) => void;
@@ -4,6 +4,43 @@ import { mergeToolCallUpdate } from "../schemas/tool-call.js";
4
4
  const logger = createLogger("chat-store", "debug");
5
5
  // Constants to avoid creating new empty arrays
6
6
  const EMPTY_TODOS = [];
7
+ /**
8
+ * Helper to create a HookNotificationDisplay from a HookNotification
9
+ */
10
+ function createHookNotificationDisplay(notification) {
11
+ const base = {
12
+ id: `hook_${Date.now()}_${notification.hookType}_${notification.callback}`,
13
+ hookType: notification.hookType,
14
+ callback: notification.callback,
15
+ };
16
+ switch (notification.type) {
17
+ case "hook_triggered":
18
+ return {
19
+ ...base,
20
+ status: "triggered",
21
+ threshold: notification.threshold,
22
+ currentPercentage: notification.currentPercentage,
23
+ // Use timestamp from backend if available, fallback to Date.now()
24
+ triggeredAt: notification.triggeredAt ?? Date.now(),
25
+ };
26
+ case "hook_completed":
27
+ return {
28
+ ...base,
29
+ status: "completed",
30
+ metadata: notification.metadata,
31
+ // Use timestamp from backend if available, fallback to Date.now()
32
+ completedAt: notification.completedAt ?? Date.now(),
33
+ };
34
+ case "hook_error":
35
+ return {
36
+ ...base,
37
+ status: "error",
38
+ error: notification.error,
39
+ // Use timestamp from backend if available, fallback to Date.now()
40
+ completedAt: notification.completedAt ?? Date.now(),
41
+ };
42
+ }
43
+ }
7
44
  // Cache for memoized todos to prevent infinite re-render loops
8
45
  let cachedTodos = {
9
46
  sessionId: null,
@@ -267,6 +304,102 @@ export const useChatStore = create((set) => ({
267
304
  };
268
305
  return { messages };
269
306
  }),
307
+ addHookNotificationToCurrentMessage: (notification) => set((state) => {
308
+ // Find the most recent assistant message
309
+ const lastAssistantIndex = state.messages.findLastIndex((msg) => msg.role === "assistant");
310
+ if (lastAssistantIndex === -1) {
311
+ // No assistant message exists yet - create one with the hook notification
312
+ logger.debug("No assistant message found, creating one for hook notification");
313
+ const hookDisplay = {
314
+ ...createHookNotificationDisplay(notification),
315
+ contentPosition: 0, // Hook at the start of the message
316
+ };
317
+ const newMessage = {
318
+ id: `msg_${Date.now()}_assistant`,
319
+ role: "assistant",
320
+ content: "",
321
+ timestamp: new Date().toISOString(),
322
+ isStreaming: false,
323
+ hookNotifications: [hookDisplay],
324
+ };
325
+ return { messages: [...state.messages, newMessage] };
326
+ }
327
+ const messages = [...state.messages];
328
+ const lastAssistantMsg = messages[lastAssistantIndex];
329
+ if (!lastAssistantMsg)
330
+ return state;
331
+ const existingNotifications = lastAssistantMsg.hookNotifications || [];
332
+ // Track the content position where this hook was triggered (for inline rendering)
333
+ const contentPosition = lastAssistantMsg.content.length;
334
+ if (notification.type === "hook_triggered") {
335
+ // Store triggered notification immediately to show loading state
336
+ logger.debug("Adding hook_triggered notification for loading state", {
337
+ hookType: notification.hookType,
338
+ callback: notification.callback,
339
+ contentPosition,
340
+ });
341
+ const hookDisplay = {
342
+ ...createHookNotificationDisplay(notification),
343
+ contentPosition,
344
+ };
345
+ const updatedNotifications = [...existingNotifications, hookDisplay];
346
+ messages[lastAssistantIndex] = {
347
+ ...lastAssistantMsg,
348
+ hookNotifications: updatedNotifications,
349
+ };
350
+ return { messages };
351
+ }
352
+ // For completed/error: find and merge with existing triggered notification
353
+ let updatedNotifications;
354
+ const existingIndex = existingNotifications.findIndex((n) => n.hookType === notification.hookType &&
355
+ n.callback === notification.callback &&
356
+ n.status === "triggered");
357
+ if (existingIndex !== -1) {
358
+ // Merge: preserve triggered data (threshold, currentPercentage, triggeredAt),
359
+ // overlay completion data
360
+ const existing = existingNotifications[existingIndex];
361
+ updatedNotifications = [...existingNotifications];
362
+ // Use backend timestamp if available, fallback to Date.now()
363
+ const completedAt = notification.type === "hook_completed" ||
364
+ notification.type === "hook_error"
365
+ ? (notification.completedAt ?? Date.now())
366
+ : Date.now();
367
+ updatedNotifications[existingIndex] = {
368
+ ...existing, // Preserves threshold, currentPercentage, triggeredAt
369
+ status: notification.type === "hook_completed" ? "completed" : "error",
370
+ completedAt,
371
+ ...(notification.type === "hook_completed" && notification.metadata
372
+ ? { metadata: notification.metadata }
373
+ : {}),
374
+ ...(notification.type === "hook_error"
375
+ ? { error: notification.error }
376
+ : {}),
377
+ };
378
+ logger.debug("Merged hook notification with triggered state", {
379
+ hookType: notification.hookType,
380
+ status: updatedNotifications[existingIndex]?.status,
381
+ });
382
+ }
383
+ else {
384
+ // No triggered notification found - add completed/error directly
385
+ // This handles cases where triggered was missed or hooks complete very fast
386
+ const hookDisplay = {
387
+ ...createHookNotificationDisplay(notification),
388
+ contentPosition,
389
+ };
390
+ updatedNotifications = [...existingNotifications, hookDisplay];
391
+ logger.debug("Added hook notification without prior triggered state", {
392
+ hookType: notification.hookType,
393
+ callback: notification.callback,
394
+ contentPosition,
395
+ });
396
+ }
397
+ messages[lastAssistantIndex] = {
398
+ ...lastAssistantMsg,
399
+ hookNotifications: updatedNotifications,
400
+ };
401
+ return { messages };
402
+ }),
270
403
  updateToolCall: (sessionId, update) => set((state) => {
271
404
  const sessionToolCalls = state.toolCalls[sessionId] || [];
272
405
  const existingIndex = sessionToolCalls.findIndex((tc) => tc.id === update.id);
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AnimatePresence, motion, useMotionValue } from "framer-motion";
3
3
  import { ArrowDown } from "lucide-react";
4
4
  import * as React from "react";
5
+ import { useScrollToBottom } from "../hooks/use-scroll-to-bottom.js";
5
6
  import { motionEasing } from "../lib/motion.js";
6
7
  import { cn } from "../lib/utils.js";
7
8
  import { Toaster } from "./Sonner.js";
@@ -60,60 +61,19 @@ const ChatLayoutBody = React.forwardRef(({ showToaster = true, className, childr
60
61
  });
61
62
  ChatLayoutBody.displayName = "ChatLayout.Body";
62
63
  const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChange, showScrollToBottom = true, initialScrollToBottom = true, ...props }, ref) => {
63
- const [showScrollButton, setShowScrollButton] = React.useState(false);
64
- const scrollContainerRef = React.useRef(null);
65
- const hasInitialScrolledRef = React.useRef(false); // Track if initial scroll has happened
64
+ const { containerRef, endRef, isAtBottom, scrollToBottom } = useScrollToBottom();
65
+ const hasInitialScrolledRef = React.useRef(false);
66
66
  // Merge refs
67
- React.useImperativeHandle(ref, () => scrollContainerRef.current);
68
- // Check if user is at bottom of scroll
69
- const checkScrollPosition = React.useCallback(() => {
70
- const container = scrollContainerRef.current;
71
- if (!container)
72
- return false;
73
- const { scrollTop, scrollHeight, clientHeight } = container;
74
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
75
- const isAtBottom = distanceFromBottom < 100; // 100px threshold
76
- setShowScrollButton(!isAtBottom && showScrollToBottom);
77
- onScrollChange?.(isAtBottom);
78
- return isAtBottom;
79
- }, [onScrollChange, showScrollToBottom]);
80
- // Handle scroll events - update button visibility
81
- const handleScroll = React.useCallback(() => {
82
- checkScrollPosition();
83
- }, [checkScrollPosition]);
84
- // Scroll to bottom function (for button click)
85
- const scrollToBottom = React.useCallback((smooth = true) => {
86
- const container = scrollContainerRef.current;
87
- if (!container)
88
- return;
89
- container.scrollTo({
90
- top: container.scrollHeight,
91
- behavior: smooth ? "smooth" : "auto",
92
- });
93
- }, []);
94
- // Auto-scroll when content changes ONLY if user is currently at the bottom
67
+ React.useImperativeHandle(ref, () => containerRef.current);
68
+ // Notify parent of scroll position changes
95
69
  React.useEffect(() => {
96
- const container = scrollContainerRef.current;
97
- if (!container)
98
- return;
99
- // Check if user is CURRENTLY at the bottom (not just "was" at bottom)
100
- const { scrollTop, scrollHeight, clientHeight } = container;
101
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
102
- const isCurrentlyAtBottom = distanceFromBottom < 100;
103
- // Only auto-scroll if user is at the bottom right now
104
- if (isCurrentlyAtBottom) {
105
- requestAnimationFrame(() => {
106
- container.scrollTop = container.scrollHeight;
107
- });
108
- }
109
- // Update the scroll button visibility
110
- setShowScrollButton(!isCurrentlyAtBottom && showScrollToBottom);
111
- }, [children, showScrollToBottom]);
70
+ onScrollChange?.(isAtBottom);
71
+ }, [isAtBottom, onScrollChange]);
112
72
  // Scroll to bottom on initial mount only (for session replay)
113
73
  React.useEffect(() => {
114
74
  if (!initialScrollToBottom)
115
75
  return undefined;
116
- const container = scrollContainerRef.current;
76
+ const container = containerRef.current;
117
77
  if (!container)
118
78
  return undefined;
119
79
  // Only scroll on initial mount, not on subsequent renders
@@ -126,8 +86,10 @@ const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChan
126
86
  return () => clearTimeout(timeout);
127
87
  }
128
88
  return undefined;
129
- }, [initialScrollToBottom]);
130
- return (_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsx("div", { ref: scrollContainerRef, className: cn("h-full overflow-y-auto flex flex-col", className), onScroll: handleScroll, ...props, children: _jsx("div", { className: "mx-auto max-w-chat flex-1 w-full flex flex-col", children: children }) }), showScrollButton && (_jsx("button", { type: "button", onClick: () => scrollToBottom(true), className: cn("absolute bottom-4 left-1/2 -translate-x-1/2 z-10", "flex items-center justify-center p-2 rounded-full", "bg-card border border-border shadow-lg", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-all duration-200 ease-in-out", "animate-in fade-in slide-in-from-bottom-2"), "aria-label": "Scroll to bottom", children: _jsx(ArrowDown, { className: "size-4" }) }))] }));
89
+ }, [initialScrollToBottom, containerRef]);
90
+ // Show scroll button when not at bottom
91
+ const showScrollButton = !isAtBottom && showScrollToBottom;
92
+ return (_jsxs("div", { className: "relative flex-1 overflow-hidden", children: [_jsxs("div", { ref: containerRef, className: cn("h-full overflow-y-auto flex flex-col", className), ...props, children: [_jsx("div", { className: "mx-auto max-w-chat flex-1 w-full flex flex-col", children: children }), _jsx("div", { ref: endRef, className: "min-h-[24px] min-w-[24px] shrink-0" })] }), showScrollButton && (_jsx("button", { type: "button", onClick: () => scrollToBottom("smooth"), className: cn("absolute bottom-4 left-1/2 -translate-x-1/2 z-10", "flex items-center justify-center p-2 rounded-full", "bg-card border border-border shadow-lg", "text-foreground", "hover:bg-accent hover:text-accent-foreground", "transition-all duration-200 ease-in-out", "animate-in fade-in slide-in-from-bottom-2"), "aria-label": "Scroll to bottom", children: _jsx(ArrowDown, { className: "size-4" }) }))] }));
131
93
  });
132
94
  ChatLayoutMessages.displayName = "ChatLayout.Messages";
133
95
  const ChatLayoutFooter = React.forwardRef(({ className, children, ...props }, ref) => {
@@ -33,7 +33,7 @@ function OpenFilesButton({ children, }) {
33
33
  // Note: Keyboard shortcut (Cmd+B / Ctrl+B) for toggling the right panel
34
34
  // is now handled internally by ChatLayout.Root
35
35
  // Chat input with attachment handling
36
- function ChatInputWithAttachments({ client, startSession, placeholder, latestContextSize, currentModel, commandMenuItems, }) {
36
+ function ChatInputWithAttachments({ client, startSession, placeholder, latestContextSize, commandMenuItems, }) {
37
37
  const attachedFiles = useChatStore((state) => state.input.attachedFiles);
38
38
  const addFileAttachment = useChatStore((state) => state.addFileAttachment);
39
39
  const removeFileAttachment = useChatStore((state) => state.removeFileAttachment);
@@ -42,7 +42,7 @@ function ChatInputWithAttachments({ client, startSession, placeholder, latestCon
42
42
  addFileAttachment(file);
43
43
  }
44
44
  };
45
- return (_jsxs(ChatInputRoot, { client: client, startSession: startSession, children: [_jsx(ChatInputCommandMenu, { commands: commandMenuItems }), attachedFiles.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 p-3 border-b border-border", children: attachedFiles.map((file, index) => (_jsxs("div", { className: "relative group rounded-md overflow-hidden border border-border", children: [_jsx("img", { src: `data:${file.mimeType};base64,${file.data}`, alt: file.name, className: "h-20 w-20 object-cover" }), _jsx("button", { type: "button", onClick: () => removeFileAttachment(index), className: "absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-background opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(X, { className: "size-3" }) })] }, index))) })), _jsx(ChatInputField, { placeholder: placeholder, autoFocus: true, onFilesDropped: handleFilesSelected }), _jsxs(ChatInputToolbar, { children: [_jsxs("div", { className: "flex items-baseline gap-1", children: [_jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, { onFilesSelected: handleFilesSelected }), latestContextSize != null && (_jsx(ContextUsageButton, { contextSize: latestContextSize, modelContextWindow: currentModel?.includes("claude") ? 200000 : 128000 }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ChatInputVoiceInput, {}), _jsx(ChatInputSubmit, { children: _jsx(ArrowUp, { className: "size-4" }) })] })] })] }));
45
+ return (_jsxs(ChatInputRoot, { client: client, startSession: startSession, children: [_jsx(ChatInputCommandMenu, { commands: commandMenuItems }), attachedFiles.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 p-3 border-b border-border", children: attachedFiles.map((file, index) => (_jsxs("div", { className: "relative group rounded-md overflow-hidden border border-border", children: [_jsx("img", { src: `data:${file.mimeType};base64,${file.data}`, alt: file.name, className: "h-20 w-20 object-cover" }), _jsx("button", { type: "button", onClick: () => removeFileAttachment(index), className: "absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-background opacity-0 group-hover:opacity-100 transition-opacity", children: _jsx(X, { className: "size-3" }) })] }, index))) })), _jsx(ChatInputField, { placeholder: placeholder, autoFocus: true, onFilesDropped: handleFilesSelected }), _jsxs(ChatInputToolbar, { children: [_jsxs("div", { className: "flex items-baseline gap-1", children: [_jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, { onFilesSelected: handleFilesSelected }), latestContextSize != null && (_jsx(ContextUsageButton, { contextSize: latestContextSize }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ChatInputVoiceInput, {}), _jsx(ChatInputSubmit, { children: _jsx(ArrowUp, { className: "size-4" }) })] })] })] }));
46
46
  }
47
47
  // Controlled Tabs component for the aside panel
48
48
  function AsideTabs({ todos, tools, mcps, subagents, }) {
@@ -70,7 +70,6 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
70
70
  const { messages, sendMessage } = useChatMessages(client, startSession);
71
71
  useToolCalls(client); // Still need to subscribe to tool call events
72
72
  const error = useChatStore((state) => state.error);
73
- const currentModel = useChatStore((state) => state.currentModel);
74
73
  const [agentName, setAgentName] = useState("Agent");
75
74
  const [agentDescription, setAgentDescription] = useState("This research agent can help you find and summarize information, analyze sources, track tasks, and answer questions about your research. Start by typing a message below to begin your investigation.");
76
75
  const [suggestedPrompts, setSuggestedPrompts] = useState([
@@ -242,5 +241,5 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
242
241
  : "mt-6";
243
242
  }
244
243
  return (_jsx(Message, { message: message, className: spacingClass, isLastMessage: index === messages.length - 1, children: _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }) }, message.id));
245
- }) })) }), _jsx(ChatLayout.Footer, { children: _jsx(ChatInputWithAttachments, { client: client, startSession: startSession, placeholder: placeholder, latestContextSize: latestContextSize, currentModel: currentModel, commandMenuItems: commandMenuItems }) })] })] }), isLargeScreen && (_jsx(ChatLayout.Aside, { breakpoint: "lg", children: _jsx(AsideTabs, { todos: todos, tools: agentTools, mcps: agentMcps, subagents: agentSubagents }) }))] }) })] }));
244
+ }) })) }), _jsx(ChatLayout.Footer, { children: _jsx(ChatInputWithAttachments, { client: client, startSession: startSession, placeholder: placeholder, latestContextSize: latestContextSize, commandMenuItems: commandMenuItems }) })] })] }), isLargeScreen && (_jsx(ChatLayout.Aside, { breakpoint: "lg", children: _jsx(AsideTabs, { todos: todos, tools: agentTools, mcps: agentMcps, subagents: agentSubagents }) }))] }) })] }));
246
245
  }
@@ -9,9 +9,9 @@ export interface ContextSize {
9
9
  toolResultsTokens: number;
10
10
  totalEstimated: number;
11
11
  llmReportedInputTokens?: number;
12
+ modelContextWindow?: number;
12
13
  }
13
14
  export interface ContextUsageButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
14
15
  contextSize: ContextSize;
15
- modelContextWindow: number;
16
16
  }
17
17
  export declare const ContextUsageButton: React.ForwardRefExoticComponent<ContextUsageButtonProps & React.RefAttributes<HTMLButtonElement>>;
@@ -3,9 +3,13 @@ import * as React from "react";
3
3
  import { cn } from "../lib/utils.js";
4
4
  import { Button } from "./Button.js";
5
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./Tooltip.js";
6
- export const ContextUsageButton = React.forwardRef(({ contextSize, modelContextWindow, className, ...props }, ref) => {
7
- // Use max of estimated and LLM-reported tokens (LLM reported as fallback if higher)
6
+ // Default context window for backward compatibility (should not be used in practice)
7
+ const DEFAULT_MODEL_CONTEXT_WINDOW = 200000;
8
+ export const ContextUsageButton = React.forwardRef(({ contextSize, className, ...props }, ref) => {
9
+ // Use max of estimated and LLM-reported tokens (same logic as backend hook executor)
8
10
  const actualTokens = Math.max(contextSize.totalEstimated, contextSize.llmReportedInputTokens ?? 0);
11
+ // Use model context window from backend, or default for backward compatibility
12
+ const modelContextWindow = contextSize.modelContextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
9
13
  const percentage = (actualTokens / modelContextWindow) * 100;
10
14
  const formattedPercentage = `${percentage.toFixed(1)}%`;
11
15
  // Clamp percentage between 0 and 100 for display
@@ -0,0 +1,9 @@
1
+ import type { HookNotificationDisplay } from "../../core/schemas/chat.js";
2
+ export interface HookNotificationProps {
3
+ notification: HookNotificationDisplay;
4
+ }
5
+ /**
6
+ * HookNotification component - displays a hook notification inline with messages
7
+ * Shows triggered (loading), completed, or error states
8
+ */
9
+ export declare function HookNotification({ notification }: HookNotificationProps): import("react/jsx-runtime").JSX.Element;