@townco/ui 0.1.69 → 0.1.70

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.
@@ -13,16 +13,16 @@ export type ToolCallStatus = z.infer<typeof ToolCallStatusSchema>;
13
13
  * Tool call categories for UI presentation
14
14
  */
15
15
  export declare const ToolCallKindSchema: z.ZodEnum<{
16
+ search: "search";
17
+ execute: "execute";
18
+ move: "move";
19
+ other: "other";
16
20
  read: "read";
17
21
  edit: "edit";
18
22
  delete: "delete";
19
- move: "move";
20
- search: "search";
21
- execute: "execute";
22
23
  think: "think";
23
24
  fetch: "fetch";
24
25
  switch_mode: "switch_mode";
25
- other: "other";
26
26
  }>;
27
27
  export type ToolCallKind = z.infer<typeof ToolCallKindSchema>;
28
28
  /**
@@ -280,16 +280,16 @@ export declare const ToolCallSchema: z.ZodObject<{
280
280
  }, z.core.$strip>>;
281
281
  subline: z.ZodOptional<z.ZodString>;
282
282
  kind: z.ZodEnum<{
283
+ search: "search";
284
+ execute: "execute";
285
+ move: "move";
286
+ other: "other";
283
287
  read: "read";
284
288
  edit: "edit";
285
289
  delete: "delete";
286
- move: "move";
287
- search: "search";
288
- execute: "execute";
289
290
  think: "think";
290
291
  fetch: "fetch";
291
292
  switch_mode: "switch_mode";
292
- other: "other";
293
293
  }>;
294
294
  status: z.ZodEnum<{
295
295
  pending: "pending";
@@ -1,7 +1,7 @@
1
1
  import { type VariantProps } from "class-variance-authority";
2
2
  import * as React from "react";
3
3
  declare const buttonVariants: (props?: ({
4
- variant?: "default" | "link" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
4
+ variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
5
5
  size?: "default" | "icon" | "sm" | "lg" | null | undefined;
6
6
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
7
7
  export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
@@ -62,8 +62,6 @@ ChatLayoutBody.displayName = "ChatLayout.Body";
62
62
  const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChange, showScrollToBottom = true, initialScrollToBottom = true, ...props }, ref) => {
63
63
  const [showScrollButton, setShowScrollButton] = React.useState(false);
64
64
  const scrollContainerRef = React.useRef(null);
65
- const wasAtBottomRef = React.useRef(true); // Track if user was at bottom before content update
66
- const isAutoScrollingRef = React.useRef(false); // Track if we're programmatically scrolling
67
65
  const hasInitialScrolledRef = React.useRef(false); // Track if initial scroll has happened
68
66
  // Merge refs
69
67
  React.useImperativeHandle(ref, () => scrollContainerRef.current);
@@ -79,170 +77,57 @@ const ChatLayoutMessages = React.forwardRef(({ className, children, onScrollChan
79
77
  onScrollChange?.(isAtBottom);
80
78
  return isAtBottom;
81
79
  }, [onScrollChange, showScrollToBottom]);
82
- // Handle scroll events
80
+ // Handle scroll events - update button visibility
83
81
  const handleScroll = React.useCallback(() => {
84
- // If this is a programmatic scroll, don't update wasAtBottomRef
85
- if (isAutoScrollingRef.current) {
86
- return;
87
- }
88
- // This is a user-initiated scroll, update the position
89
- const isAtBottom = checkScrollPosition();
90
- wasAtBottomRef.current = isAtBottom;
82
+ checkScrollPosition();
91
83
  }, [checkScrollPosition]);
92
- // Scroll to bottom function
84
+ // Scroll to bottom function (for button click)
93
85
  const scrollToBottom = React.useCallback((smooth = true) => {
94
86
  const container = scrollContainerRef.current;
95
87
  if (!container)
96
88
  return;
97
- // Mark that we're about to programmatically scroll
98
- isAutoScrollingRef.current = true;
99
- wasAtBottomRef.current = true; // Set immediately for instant scrolls
100
89
  container.scrollTo({
101
90
  top: container.scrollHeight,
102
91
  behavior: smooth ? "smooth" : "auto",
103
92
  });
104
- // Clear the flag after scroll completes
105
- // For instant scrolling, clear immediately; for smooth, wait
106
- setTimeout(() => {
107
- isAutoScrollingRef.current = false;
108
- }, smooth ? 300 : 0);
109
93
  }, []);
110
- // Auto-scroll when content changes if user was at bottom
94
+ // Auto-scroll when content changes ONLY if user is currently at the bottom
111
95
  React.useEffect(() => {
112
96
  const container = scrollContainerRef.current;
113
97
  if (!container)
114
98
  return;
115
- // If user was at the bottom, scroll to new content
116
- if (wasAtBottomRef.current && !isAutoScrollingRef.current) {
117
- // Use requestAnimationFrame to ensure DOM has updated
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) {
118
105
  requestAnimationFrame(() => {
119
- scrollToBottom(false); // Use instant scroll for streaming to avoid jarring smooth animations
106
+ container.scrollTop = container.scrollHeight;
120
107
  });
121
108
  }
122
- // Update scroll position state (but don't change wasAtBottomRef if we're auto-scrolling)
123
- if (!isAutoScrollingRef.current) {
124
- checkScrollPosition();
125
- }
126
- }, [children, scrollToBottom, checkScrollPosition]);
127
- // Track last scroll height to detect when content stops loading
128
- const lastScrollHeightRef = React.useRef(0);
129
- const scrollStableCountRef = React.useRef(0);
130
- // Scroll to bottom on initial mount and during session loading
131
- // Keep scrolling until content stabilizes (no more changes)
109
+ // Update the scroll button visibility
110
+ setShowScrollButton(!isCurrentlyAtBottom && showScrollToBottom);
111
+ }, [children, showScrollToBottom]);
112
+ // Scroll to bottom on initial mount only (for session replay)
132
113
  React.useEffect(() => {
133
114
  if (!initialScrollToBottom)
134
- return;
115
+ return undefined;
135
116
  const container = scrollContainerRef.current;
136
117
  if (!container)
137
- return;
138
- const scrollToBottomInstant = () => {
139
- container.scrollTop = container.scrollHeight;
140
- wasAtBottomRef.current = true;
141
- };
142
- // Check if content has stabilized (scrollHeight hasn't changed)
143
- const currentHeight = container.scrollHeight;
144
- if (currentHeight === lastScrollHeightRef.current) {
145
- scrollStableCountRef.current++;
146
- }
147
- else {
148
- scrollStableCountRef.current = 0;
149
- lastScrollHeightRef.current = currentHeight;
150
- }
151
- // If content is still loading (height changing) or we haven't scrolled yet,
152
- // keep auto-scrolling. Stop after content is stable for a few renders.
153
- if (scrollStableCountRef.current < 3) {
154
- isAutoScrollingRef.current = true;
155
- scrollToBottomInstant();
156
- hasInitialScrolledRef.current = true;
157
- }
158
- else {
159
- // Content is stable, stop auto-scrolling
160
- isAutoScrollingRef.current = false;
161
- }
162
- }, [initialScrollToBottom, children]);
163
- // Also use a timer-based approach as backup for session replay
164
- // which may not trigger children changes
165
- React.useEffect(() => {
166
- if (!initialScrollToBottom)
167
- return;
168
- const container = scrollContainerRef.current;
169
- if (!container)
170
- return;
171
- // Keep scrolling to bottom for the first 2 seconds of session load
172
- // to catch async message replay
173
- let cancelled = false;
174
- const scrollInterval = setInterval(() => {
175
- if (cancelled)
176
- return;
177
- if (container.scrollHeight > container.clientHeight) {
178
- isAutoScrollingRef.current = true;
118
+ return undefined;
119
+ // Only scroll on initial mount, not on subsequent renders
120
+ if (!hasInitialScrolledRef.current) {
121
+ // Use a small delay to let initial content render
122
+ const timeout = setTimeout(() => {
179
123
  container.scrollTop = container.scrollHeight;
180
- wasAtBottomRef.current = true;
181
124
  hasInitialScrolledRef.current = true;
182
- }
183
- }, 100);
184
- // Stop after 2 seconds
185
- const timeout = setTimeout(() => {
186
- clearInterval(scrollInterval);
187
- isAutoScrollingRef.current = false;
188
- }, 2000);
189
- return () => {
190
- cancelled = true;
191
- clearInterval(scrollInterval);
192
- clearTimeout(timeout);
193
- };
194
- }, [initialScrollToBottom]); // Only run once on mount
195
- // Check scroll position on mount
196
- React.useEffect(() => {
197
- if (!isAutoScrollingRef.current) {
198
- const isAtBottom = checkScrollPosition();
199
- wasAtBottomRef.current = isAtBottom;
125
+ }, 100);
126
+ return () => clearTimeout(timeout);
200
127
  }
201
- }, [checkScrollPosition]);
202
- // Detect user interaction with scroll area (wheel, touch) - IMMEDIATELY break auto-scroll
203
- const handleUserInteraction = React.useCallback(() => {
204
- // Immediately mark that user is interacting
205
- isAutoScrollingRef.current = false;
206
- // For wheel/touch events, temporarily break auto-scroll
207
- // The actual scroll event will update wasAtBottomRef properly
208
- // This prevents the race condition where content updates before scroll completes
209
- const container = scrollContainerRef.current;
210
- if (!container)
211
- return;
212
- // Check current position BEFORE the scroll happens
213
- const { scrollTop, scrollHeight, clientHeight } = container;
214
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
215
- // If user is not currently at the bottom, definitely break auto-scroll
216
- if (distanceFromBottom >= 100) {
217
- wasAtBottomRef.current = false;
218
- }
219
- // If they are at bottom, the scroll event will determine if they stay there
220
- }, []);
221
- // Handle keyboard navigation
222
- const handleKeyDown = React.useCallback((e) => {
223
- // If user presses arrow keys, page up/down, home/end - they're scrolling
224
- const scrollKeys = [
225
- "ArrowUp",
226
- "ArrowDown",
227
- "PageUp",
228
- "PageDown",
229
- "Home",
230
- "End",
231
- ];
232
- if (scrollKeys.includes(e.key)) {
233
- isAutoScrollingRef.current = false;
234
- // Check position on next frame after the scroll happens
235
- requestAnimationFrame(() => {
236
- const container = scrollContainerRef.current;
237
- if (!container)
238
- return;
239
- const { scrollTop, scrollHeight, clientHeight } = container;
240
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
241
- wasAtBottomRef.current = distanceFromBottom < 100;
242
- });
243
- }
244
- }, []);
245
- 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, onWheel: handleUserInteraction, onTouchStart: handleUserInteraction, onKeyDown: handleKeyDown, tabIndex: 0, ...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" }) }))] }));
128
+ 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" }) }))] }));
246
131
  });
247
132
  ChatLayoutMessages.displayName = "ChatLayout.Messages";
248
133
  const ChatLayoutFooter = React.forwardRef(({ className, children, ...props }, ref) => {
@@ -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
+ * Only shows completed or error states (not intermediate "triggered" state)
8
+ */
9
+ export declare function HookNotification({ notification }: HookNotificationProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AlertCircle, Archive, CheckCircle2, ChevronDown, Scissors, } from "lucide-react";
3
+ import React, { useState } from "react";
4
+ /**
5
+ * Get display information for a hook type
6
+ */
7
+ function getHookDisplayInfo(hookType, _callback) {
8
+ if (hookType === "context_size") {
9
+ return {
10
+ icon: Archive,
11
+ title: "Context Compacted",
12
+ };
13
+ }
14
+ if (hookType === "tool_response") {
15
+ return {
16
+ icon: Scissors,
17
+ title: "Tool Response Compacted",
18
+ };
19
+ }
20
+ // Fallback for unknown hook types
21
+ return {
22
+ icon: Archive,
23
+ title: `Hook Executed`,
24
+ };
25
+ }
26
+ /**
27
+ * Format a number with thousand separators
28
+ */
29
+ function formatNumber(num) {
30
+ return num.toLocaleString();
31
+ }
32
+ /**
33
+ * HookNotification component - displays a hook notification inline with messages
34
+ * Only shows completed or error states (not intermediate "triggered" state)
35
+ */
36
+ export function HookNotification({ notification }) {
37
+ const [isExpanded, setIsExpanded] = useState(false);
38
+ const { icon: IconComponent, title } = getHookDisplayInfo(notification.hookType, notification.callback);
39
+ const isCompleted = notification.status === "completed";
40
+ const isError = notification.status === "error";
41
+ // Build subtitle showing key info
42
+ let subtitle = "";
43
+ if (isCompleted && notification.metadata?.tokensSaved !== undefined) {
44
+ subtitle = `${formatNumber(notification.metadata.tokensSaved)} tokens saved`;
45
+ }
46
+ else if (isError && notification.error) {
47
+ subtitle = notification.error;
48
+ }
49
+ 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 }), isCompleted && _jsx(CheckCircle2, { className: "h-3 w-3 text-green-500" }), isError && _jsx(AlertCircle, { className: "h-3 w-3 text-destructive" }), _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 && (_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.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.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 })] })), notification.completedAt && (_jsxs("div", { className: "p-2 bg-muted/50 border-t border-border text-[10px] text-muted-foreground font-sans", children: ["Executed:", " ", new Date(notification.completedAt).toLocaleTimeString()] }))] }))] }));
50
+ }
@@ -0,0 +1,9 @@
1
+ import type { ToolCall as ToolCallType } from "../../core/schemas/tool-call.js";
2
+ export interface InvokingGroupProps {
3
+ toolCalls: ToolCallType[];
4
+ }
5
+ /**
6
+ * InvokingGroup component - displays a group of preliminary (invoking) tool calls
7
+ * Shows as "Invoking parallel operation (N)" with a summary of unique tool names
8
+ */
9
+ export declare function InvokingGroup({ toolCalls }: InvokingGroupProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ListVideo } from "lucide-react";
3
+ import React from "react";
4
+ /**
5
+ * InvokingGroup component - displays a group of preliminary (invoking) tool calls
6
+ * Shows as "Invoking parallel operation (N)" with a summary of unique tool names
7
+ */
8
+ export function InvokingGroup({ toolCalls }) {
9
+ // Get unique display names for the summary
10
+ const displayNames = toolCalls.map((tc) => tc.prettyName || tc.title);
11
+ const uniqueNames = [...new Set(displayNames)];
12
+ const summary = uniqueNames.length <= 2
13
+ ? uniqueNames.join(", ")
14
+ : `${uniqueNames.slice(0, 2).join(", ")} +${uniqueNames.length - 2} more`;
15
+ return (_jsxs("div", { className: "flex flex-col my-4", children: [_jsxs("div", { className: "flex items-center gap-1.5 text-paragraph-sm text-muted-foreground/50", children: [_jsx(ListVideo, { className: "h-3 w-3" }), _jsx("span", { children: "Invoking parallel operation" }), _jsx("span", { className: "text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground/50", children: toolCalls.length })] }), _jsx("span", { className: "text-paragraph-sm text-muted-foreground/50 pl-4.5", children: summary })] }));
16
+ }
@@ -19,7 +19,8 @@ export function SubAgentDetails({ port, sessionId, host, parentStatus, agentName
19
19
  // Use controlled state if provided, otherwise use internal state
20
20
  const isExpanded = controlledIsExpanded ?? internalIsExpanded;
21
21
  const setIsExpanded = onExpandChange ?? setInternalIsExpanded;
22
- const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
22
+ // Start with Thinking expanded while running to show the live stream
23
+ const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
23
24
  const [isNearBottom, setIsNearBottom] = useState(true);
24
25
  const thinkingContainerRef = useRef(null);
25
26
  // Only use SSE streaming if not in replay mode and port/sessionId provided
@@ -99,6 +100,16 @@ export function SubAgentDetails({ port, sessionId, host, parentStatus, agentName
99
100
  checkScrollPosition(); // Check initial position
100
101
  return () => container.removeEventListener("scroll", handleScroll);
101
102
  }, [checkScrollPosition, isThinkingExpanded, isExpanded]);
103
+ // When thinking section expands, scroll to bottom and reset follow state
104
+ useEffect(() => {
105
+ if (isThinkingExpanded) {
106
+ setIsNearBottom(true);
107
+ // Use requestAnimationFrame to ensure DOM has rendered
108
+ requestAnimationFrame(() => {
109
+ scrollToBottom();
110
+ });
111
+ }
112
+ }, [isThinkingExpanded, scrollToBottom]);
102
113
  // Get last line of streaming content for preview
103
114
  const lastLine = currentMessage?.content
104
115
  ? currentMessage.content.split("\n").filter(Boolean).pop() || ""
@@ -109,7 +120,7 @@ export function SubAgentDetails({ port, sessionId, host, parentStatus, agentName
109
120
  ? (query.split("\n")[0] ?? "").slice(0, 100) +
110
121
  (query.length > 100 ? "..." : "")
111
122
  : "";
112
- return (_jsxs("div", { children: [!isExpanded && (_jsx("div", { className: "w-full max-w-md", children: previewText ? (_jsx("p", { className: "text-paragraph-sm text-muted-foreground/70 truncate", children: previewText })) : queryFirstLine ? (_jsx("p", { className: "text-paragraph-sm text-muted-foreground/50 truncate", children: queryFirstLine })) : null })), isExpanded && (_jsxs("div", { className: "space-y-3", children: [(agentName || query) && (_jsxs("div", { children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Input" }), _jsxs("div", { className: "text-[11px] font-mono space-y-1", children: [agentName && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "agentName: " }), _jsx("span", { className: "text-foreground", children: agentName })] })), query && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "query: " }), _jsx("span", { className: "text-foreground", children: query })] }))] })] })), _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setIsThinkingExpanded(!isThinkingExpanded), className: "flex items-center gap-2 cursor-pointer bg-transparent border-none p-0 text-left group", children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider font-sans", children: "Thinking" }), _jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isThinkingExpanded ? "rotate-180" : ""}` })] }), isThinkingExpanded && (_jsxs("div", { ref: thinkingContainerRef, className: "mt-2 rounded-md overflow-hidden bg-muted/30 border border-border/50 max-h-[200px] overflow-y-auto", children: [error && (_jsxs("div", { className: "px-2 py-2 text-[11px] text-destructive", children: ["Error: ", error] })), !error && !hasContent && isRunning && (_jsx("div", { className: "px-2 py-2 text-[11px] text-muted-foreground", children: "Waiting for sub-agent response..." })), currentMessage && (_jsxs("div", { className: "px-2 py-2 space-y-2", children: [currentMessage.contentBlocks &&
123
+ return (_jsxs("div", { children: [!isExpanded && (_jsx("div", { className: "w-full max-w-md", children: previewText ? (_jsx("p", { className: "text-paragraph-sm text-muted-foreground/70 truncate", children: previewText })) : queryFirstLine ? (_jsx("p", { className: "text-paragraph-sm text-muted-foreground/50 truncate", children: queryFirstLine })) : null })), isExpanded && (_jsxs("div", { className: "space-y-3", children: [(agentName || query) && (_jsxs("div", { children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Input" }), _jsxs("div", { className: "text-[11px] font-mono space-y-1", children: [agentName && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "agentName: " }), _jsx("span", { className: "text-foreground", children: agentName })] })), query && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "query: " }), _jsx("span", { className: "text-foreground", children: query })] }))] })] })), _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setIsThinkingExpanded(!isThinkingExpanded), className: "flex items-center gap-2 cursor-pointer bg-transparent border-none p-0 text-left group", children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider font-sans", children: "Stream" }), _jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isThinkingExpanded ? "rotate-180" : ""}` })] }), isThinkingExpanded && (_jsxs("div", { ref: thinkingContainerRef, className: "mt-2 rounded-md overflow-hidden bg-muted/30 border border-border/50 max-h-[200px] overflow-y-auto", children: [error && (_jsxs("div", { className: "px-2 py-2 text-[11px] text-destructive", children: ["Error: ", error] })), !error && !hasContent && isRunning && (_jsx("div", { className: "px-2 py-2 text-[11px] text-muted-foreground", children: "Waiting for sub-agent response..." })), currentMessage && (_jsxs("div", { className: "px-2 py-2 space-y-2", children: [currentMessage.contentBlocks &&
113
124
  currentMessage.contentBlocks.length > 0
114
125
  ? // Render interleaved content blocks
115
126
  currentMessage.contentBlocks.map((block, idx) => block.type === "text" ? (_jsx("div", { className: "text-[11px] text-foreground prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-pre:my-1 prose-code:text-[10px]", children: _jsx(MarkdownRenderer, { content: block.text }) }, `text-${idx}`)) : (_jsx(SubagentToolCallItem, { toolCall: block.toolCall }, block.toolCall.id)))
@@ -0,0 +1,23 @@
1
+ export interface SubagentStreamProps {
2
+ /** Sub-agent HTTP port */
3
+ port: number;
4
+ /** Sub-agent session ID */
5
+ sessionId: string;
6
+ /** Optional host (defaults to localhost) */
7
+ host?: string;
8
+ /** Parent tool call status - use this to determine if sub-agent is running */
9
+ parentStatus?: "pending" | "in_progress" | "completed" | "failed";
10
+ /** Sub-agent name (for display) */
11
+ agentName?: string | undefined;
12
+ /** Query sent to the sub-agent */
13
+ query?: string | undefined;
14
+ }
15
+ /**
16
+ * SubagentStream component - displays streaming content from a sub-agent.
17
+ *
18
+ * This component:
19
+ * - Connects directly to the sub-agent's SSE endpoint
20
+ * - Displays streaming text and tool calls
21
+ * - Renders in a collapsible section (collapsed by default)
22
+ */
23
+ export declare function SubagentStream({ port, sessionId, host, parentStatus, agentName, query, }: SubagentStreamProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDown, CircleDot, Loader2 } from "lucide-react";
3
+ import React, { useCallback, useEffect, useRef, useState } from "react";
4
+ import { useSubagentStream } from "../../core/hooks/use-subagent-stream.js";
5
+ const SCROLL_THRESHOLD = 50; // px from bottom to consider "at bottom"
6
+ /**
7
+ * SubagentStream component - displays streaming content from a sub-agent.
8
+ *
9
+ * This component:
10
+ * - Connects directly to the sub-agent's SSE endpoint
11
+ * - Displays streaming text and tool calls
12
+ * - Renders in a collapsible section (collapsed by default)
13
+ */
14
+ export function SubagentStream({ port, sessionId, host, parentStatus, agentName, query, }) {
15
+ const [isExpanded, setIsExpanded] = useState(false); // Start collapsed for parallel ops
16
+ const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
17
+ const [isNearBottom, setIsNearBottom] = useState(true);
18
+ const thinkingContainerRef = useRef(null);
19
+ const { messages, isStreaming: hookIsStreaming, error } = useSubagentStream({
20
+ port,
21
+ sessionId,
22
+ ...(host !== undefined ? { host } : {}),
23
+ });
24
+ // Use parent status as primary indicator, fall back to hook's streaming state
25
+ // Parent is "in_progress" means sub-agent is definitely still running
26
+ const isRunning = parentStatus === "in_progress" || parentStatus === "pending" || hookIsStreaming;
27
+ // Get the current/latest message
28
+ const currentMessage = messages[messages.length - 1];
29
+ const hasContent = currentMessage &&
30
+ (currentMessage.content ||
31
+ (currentMessage.toolCalls && currentMessage.toolCalls.length > 0));
32
+ // Auto-collapse Thinking when completed (so Output is the primary view)
33
+ const prevIsRunningRef = useRef(isRunning);
34
+ useEffect(() => {
35
+ if (prevIsRunningRef.current && !isRunning) {
36
+ // Just completed - collapse thinking to show output
37
+ setIsThinkingExpanded(false);
38
+ }
39
+ prevIsRunningRef.current = isRunning;
40
+ }, [isRunning]);
41
+ // Check if user is near bottom of scroll area
42
+ const checkScrollPosition = useCallback(() => {
43
+ const container = thinkingContainerRef.current;
44
+ if (!container)
45
+ return;
46
+ const { scrollTop, scrollHeight, clientHeight } = container;
47
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
48
+ setIsNearBottom(distanceFromBottom < SCROLL_THRESHOLD);
49
+ }, []);
50
+ // Scroll to bottom
51
+ const scrollToBottom = useCallback(() => {
52
+ const container = thinkingContainerRef.current;
53
+ if (!container)
54
+ return;
55
+ container.scrollTop = container.scrollHeight;
56
+ }, []);
57
+ // Auto-scroll when content changes and user is near bottom
58
+ useEffect(() => {
59
+ if (isNearBottom && (isRunning || hasContent)) {
60
+ scrollToBottom();
61
+ }
62
+ }, [currentMessage?.content, currentMessage?.toolCalls, isNearBottom, isRunning, hasContent, scrollToBottom]);
63
+ // Set up scroll listener
64
+ useEffect(() => {
65
+ const container = thinkingContainerRef.current;
66
+ if (!container)
67
+ return;
68
+ const handleScroll = () => checkScrollPosition();
69
+ container.addEventListener("scroll", handleScroll, { passive: true });
70
+ checkScrollPosition(); // Check initial position
71
+ return () => container.removeEventListener("scroll", handleScroll);
72
+ }, [checkScrollPosition, isThinkingExpanded, isExpanded]);
73
+ // Get last line of streaming content for preview
74
+ const lastLine = currentMessage?.content
75
+ ? currentMessage.content.split("\n").filter(Boolean).pop() || ""
76
+ : "";
77
+ const previewText = lastLine.length > 100 ? `${lastLine.slice(0, 100)}...` : lastLine;
78
+ return (_jsxs("div", { children: [!isExpanded && (_jsx("button", { type: "button", onClick: () => setIsExpanded(true), className: "w-full max-w-md text-left cursor-pointer bg-transparent border-none p-0", children: previewText ? (_jsx("p", { className: `text-paragraph-sm text-muted-foreground truncate ${isRunning ? "animate-pulse" : ""}`, children: previewText })) : isRunning ? (_jsx("p", { className: "text-paragraph-sm text-muted-foreground/50 italic animate-pulse", children: "Waiting for response..." })) : null })), isExpanded && (_jsxs("div", { className: "space-y-3", children: [(agentName || query) && (_jsxs("div", { children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Input" }), _jsxs("div", { className: "text-[11px] font-mono space-y-1", children: [agentName && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "agentName: " }), _jsx("span", { className: "text-foreground", children: agentName })] })), query && (_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "query: " }), _jsx("span", { className: "text-foreground", children: query })] }))] })] })), _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setIsThinkingExpanded(!isThinkingExpanded), className: "flex items-center gap-2 cursor-pointer bg-transparent border-none p-0 text-left group", children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider font-sans", children: "Thinking" }), _jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isThinkingExpanded ? "rotate-180" : ""}` })] }), isThinkingExpanded && (_jsxs("div", { ref: thinkingContainerRef, className: "mt-2 rounded-md overflow-hidden bg-muted/30 border border-border/50 max-h-[200px] overflow-y-auto", children: [error && (_jsxs("div", { className: "px-2 py-2 text-[11px] text-destructive", children: ["Error: ", error] })), !error && !hasContent && isRunning && (_jsx("div", { className: "px-2 py-2 text-[11px] text-muted-foreground", children: "Waiting for sub-agent response..." })), currentMessage && (_jsxs("div", { className: "px-2 py-2 space-y-2", children: [currentMessage.toolCalls &&
79
+ currentMessage.toolCalls.length > 0 && (_jsx("div", { className: "space-y-1", children: currentMessage.toolCalls.map((tc) => (_jsx(SubagentToolCallItem, { toolCall: tc }, tc.id))) })), currentMessage.content && (_jsxs("div", { className: "text-[11px] text-foreground whitespace-pre-wrap font-mono", children: [currentMessage.content, currentMessage.isStreaming && (_jsx("span", { className: "inline-block w-1.5 h-3 bg-primary/70 ml-0.5 animate-pulse" }))] }))] }))] }))] }), !isRunning && currentMessage?.content && (_jsxs("div", { children: [_jsx("div", { className: "text-[10px] font-bold text-muted-foreground uppercase tracking-wider mb-1.5 font-sans", children: "Output" }), _jsx("div", { className: "text-[11px] text-foreground whitespace-pre-wrap font-mono max-h-[200px] overflow-y-auto rounded-md bg-muted/30 border border-border/50 px-2 py-2", children: currentMessage.content })] }))] }))] }));
80
+ }
81
+ /**
82
+ * Simple tool call display for sub-agent tool calls
83
+ */
84
+ function SubagentToolCallItem({ toolCall }) {
85
+ const statusIcon = {
86
+ pending: "...",
87
+ in_progress: "",
88
+ completed: "",
89
+ failed: "",
90
+ }[toolCall.status];
91
+ const statusColor = {
92
+ pending: "text-muted-foreground",
93
+ in_progress: "text-blue-500",
94
+ completed: "text-green-500",
95
+ failed: "text-destructive",
96
+ }[toolCall.status];
97
+ return (_jsxs("div", { className: "flex items-center gap-2 text-[10px] text-muted-foreground", children: [_jsx("span", { className: statusColor, children: statusIcon }), _jsx("span", { className: "font-medium", children: toolCall.prettyName || toolCall.title }), toolCall.status === "in_progress" && (_jsx(Loader2, { className: "h-2.5 w-2.5 animate-spin" }))] }));
98
+ }
@@ -0,0 +1,8 @@
1
+ import type { ToolCall as ToolCallType } from "../../core/schemas/tool-call.js";
2
+ export interface ToolCallProps {
3
+ toolCall: ToolCallType;
4
+ }
5
+ /**
6
+ * ToolCall component - displays a single tool call with collapsible details
7
+ */
8
+ export declare function ToolCall({ toolCall }: ToolCallProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,234 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import JsonView from "@uiw/react-json-view";
3
+ import { AlertCircle, CheckSquare, ChevronDown, ChevronRight, CircleDot, Cloud, Edit, FileText, Globe, Image, Link, Search, Wrench, } from "lucide-react";
4
+ import React, { useState } from "react";
5
+ import { ChatLayout } from "./index.js";
6
+ import { SubAgentDetails } from "./SubAgentDetails.js";
7
+ import { useTheme } from "./ThemeProvider.js";
8
+ /**
9
+ * Map of icon names to Lucide components
10
+ */
11
+ const ICON_MAP = {
12
+ Globe: Globe,
13
+ Image: Image,
14
+ Link: Link,
15
+ Cloud: Cloud,
16
+ CheckSquare: CheckSquare,
17
+ Search: Search,
18
+ FileText: FileText,
19
+ Edit: Edit,
20
+ Wrench: Wrench,
21
+ CircleDot: CircleDot,
22
+ };
23
+ /**
24
+ * Tool call kind icons (using emoji for simplicity)
25
+ */
26
+ const _kindIcons = {
27
+ read: "\u{1F4C4}",
28
+ edit: "\u{270F}\u{FE0F}",
29
+ delete: "\u{1F5D1}\u{FE0F}",
30
+ move: "\u{1F4E6}",
31
+ search: "\u{1F50D}",
32
+ execute: "\u{2699}\u{FE0F}",
33
+ think: "\u{1F4AD}",
34
+ fetch: "\u{1F310}",
35
+ switch_mode: "\u{1F501}",
36
+ other: "\u{1F527}",
37
+ };
38
+ /**
39
+ * ToolCall component - displays a single tool call with collapsible details
40
+ */
41
+ export function ToolCall({ toolCall }) {
42
+ const [isExpanded, setIsExpanded] = useState(false);
43
+ const [isSubagentExpanded, setIsSubagentExpanded] = useState(false);
44
+ const { resolvedTheme } = useTheme();
45
+ // Detect TodoWrite tool and subagent
46
+ const isTodoWrite = toolCall.title === "todo_write";
47
+ // A subagent call can be detected by:
48
+ // - Live: has port and sessionId (but no stored messages yet)
49
+ // - Replay: has stored subagentMessages
50
+ const hasLiveSubagent = !!(toolCall.subagentPort && toolCall.subagentSessionId);
51
+ const hasStoredSubagent = !!(toolCall.subagentMessages && toolCall.subagentMessages.length > 0);
52
+ const isSubagentCall = hasLiveSubagent || hasStoredSubagent;
53
+ // Use replay mode if we have stored messages - they should take precedence
54
+ // over trying to connect to SSE (which won't work for replayed sessions)
55
+ const isReplaySubagent = hasStoredSubagent;
56
+ // Safely access ChatLayout context - will be undefined if not within ChatLayout
57
+ const layoutContext = React.useContext(ChatLayout.Context);
58
+ // Click handler: toggle sidepanel for TodoWrite, subagent details for subagents, expand for others
59
+ const handleHeaderClick = React.useCallback(() => {
60
+ if (isTodoWrite && layoutContext) {
61
+ // Toggle sidepanel - close if already open on todo tab, otherwise open
62
+ if (layoutContext.panelSize !== "hidden" &&
63
+ layoutContext.activeTab === "todo") {
64
+ layoutContext.setPanelSize("hidden");
65
+ }
66
+ else {
67
+ layoutContext.setPanelSize("small");
68
+ layoutContext.setActiveTab("todo");
69
+ }
70
+ }
71
+ else if (isSubagentCall) {
72
+ // Toggle subagent details
73
+ setIsSubagentExpanded(!isSubagentExpanded);
74
+ }
75
+ else {
76
+ // Normal expand/collapse
77
+ setIsExpanded(!isExpanded);
78
+ }
79
+ }, [
80
+ isTodoWrite,
81
+ layoutContext,
82
+ isExpanded,
83
+ isSubagentCall,
84
+ isSubagentExpanded,
85
+ ]);
86
+ // Determine which icon to show
87
+ const IconComponent = toolCall.icon && ICON_MAP[toolCall.icon]
88
+ ? ICON_MAP[toolCall.icon]
89
+ : CircleDot;
90
+ // Determine display name
91
+ const displayName = toolCall.prettyName || toolCall.title;
92
+ // Determine icon color based on status (especially for subagents)
93
+ const isSubagentRunning = isSubagentCall &&
94
+ (toolCall.status === "in_progress" || toolCall.status === "pending");
95
+ const isSubagentFailed = isSubagentCall && toolCall.status === "failed";
96
+ const iconColorClass = isSubagentCall
97
+ ? isSubagentFailed
98
+ ? "text-destructive"
99
+ : isSubagentRunning
100
+ ? "text-foreground animate-pulse"
101
+ : "text-green-500"
102
+ : "text-muted-foreground";
103
+ const statusTooltip = isSubagentCall
104
+ ? isSubagentFailed
105
+ ? "Sub-agent failed"
106
+ : isSubagentRunning
107
+ ? "Sub-agent running"
108
+ : "Sub-agent completed"
109
+ : undefined;
110
+ // Check if there's an error
111
+ const hasError = toolCall.status === "failed" || !!toolCall.error;
112
+ // Check if this is a preliminary (pending) tool call without full details yet
113
+ const isPreliminary = toolCall.status === "pending" &&
114
+ (!toolCall.rawInput || Object.keys(toolCall.rawInput).length === 0);
115
+ // JSON View style based on theme
116
+ const jsonStyle = {
117
+ fontSize: "11px",
118
+ backgroundColor: "transparent",
119
+ fontFamily: "inherit",
120
+ "--w-rjv-color": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
121
+ "--w-rjv-key-string": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
122
+ "--w-rjv-background-color": "transparent",
123
+ "--w-rjv-line-color": resolvedTheme === "dark" ? "#27272a" : "#e4e4e7",
124
+ "--w-rjv-arrow-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
125
+ "--w-rjv-edit-color": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
126
+ "--w-rjv-info-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
127
+ "--w-rjv-update-color": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
128
+ "--w-rjv-copied-color": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
129
+ "--w-rjv-copied-success-color": resolvedTheme === "dark" ? "#22c55e" : "#16a34a",
130
+ "--w-rjv-curlybraces-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
131
+ "--w-rjv-colon-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
132
+ "--w-rjv-brackets-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
133
+ "--w-rjv-quotes-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
134
+ "--w-rjv-quotes-string-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
135
+ "--w-rjv-type-string-color": resolvedTheme === "dark" ? "#22c55e" : "#16a34a",
136
+ "--w-rjv-type-int-color": resolvedTheme === "dark" ? "#f59e0b" : "#d97706",
137
+ "--w-rjv-type-float-color": resolvedTheme === "dark" ? "#f59e0b" : "#d97706",
138
+ "--w-rjv-type-bigint-color": resolvedTheme === "dark" ? "#f59e0b" : "#d97706",
139
+ "--w-rjv-type-boolean-color": resolvedTheme === "dark" ? "#3b82f6" : "#2563eb",
140
+ "--w-rjv-type-date-color": resolvedTheme === "dark" ? "#ec4899" : "#db2777",
141
+ "--w-rjv-type-url-color": resolvedTheme === "dark" ? "#3b82f6" : "#2563eb",
142
+ "--w-rjv-type-null-color": resolvedTheme === "dark" ? "#ef4444" : "#dc2626",
143
+ "--w-rjv-type-nan-color": resolvedTheme === "dark" ? "#ef4444" : "#dc2626",
144
+ "--w-rjv-type-undefined-color": resolvedTheme === "dark" ? "#ef4444" : "#dc2626",
145
+ };
146
+ // Preliminary tool calls show as simple light gray text without expansion
147
+ if (isPreliminary) {
148
+ return (_jsx("div", { className: "flex flex-col my-4", children: _jsxs("span", { className: "text-paragraph-sm text-muted-foreground/50", children: ["Invoking ", displayName] }) }));
149
+ }
150
+ return (_jsxs("div", { className: "flex flex-col my-4", 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: handleHeaderClick, "aria-expanded": isTodoWrite ? undefined : isExpanded, children: [_jsxs("div", { className: "flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground", children: [_jsx("div", { className: iconColorClass, title: statusTooltip, children: _jsx(IconComponent, { className: "h-3 w-3" }) }), _jsx("span", { className: "text-paragraph-sm text-muted-foreground", children: displayName }), hasError && _jsx(AlertCircle, { className: "h-3 w-3 text-destructive" }), isTodoWrite ? (_jsx(ChevronRight, { className: "h-3 w-3 text-muted-foreground/70" })) : (_jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}` }))] }), toolCall.subline && (_jsx("span", { className: "text-paragraph-sm text-muted-foreground/70 pl-4.5", children: toolCall.subline }))] }), !isTodoWrite && isSubagentCall && (_jsx("div", { className: "pl-4.5", children: _jsx(SubAgentDetails, { port: toolCall.subagentPort, sessionId: toolCall.subagentSessionId, parentStatus: toolCall.status, agentName: toolCall.rawInput?.agentName, query: toolCall.rawInput?.query, isExpanded: isSubagentExpanded, onExpandChange: setIsSubagentExpanded, storedMessages: toolCall.subagentMessages, isReplay: isReplaySubagent }) })), !isTodoWrite && !isSubagentCall && isExpanded && (_jsxs("div", { className: "mt-2 text-sm border border-border rounded-lg bg-card overflow-hidden w-full", children: [toolCall.rawInput &&
151
+ Object.keys(toolCall.rawInput).length > 0 &&
152
+ !toolCall.subagentPort && (_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: "Input" }), _jsx("div", { className: "text-[11px] font-mono text-foreground", children: _jsx(JsonView, { value: toolCall.rawInput, collapsed: false, displayDataTypes: false, displayObjectSize: false, enableClipboard: true, style: jsonStyle }) })] })), toolCall.locations && toolCall.locations.length > 0 && (_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: "Files" }), _jsx("ul", { className: "space-y-1", children: toolCall.locations.map((loc) => (_jsxs("li", { className: "font-mono text-[11px] text-foreground bg-muted px-1.5 py-0.5 rounded w-fit", children: [loc.path, loc.line !== null &&
153
+ loc.line !== undefined &&
154
+ `:${loc.line}`] }, `${loc.path}:${loc.line ?? ""}`))) })] })), (toolCall.content && toolCall.content.length > 0) ||
155
+ toolCall.error ? (_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: "Output" }), _jsxs("div", { className: "space-y-2 text-[11px] text-foreground", children: [toolCall.content?.map((block, idx) => {
156
+ // Generate a stable key based on content
157
+ const getBlockKey = () => {
158
+ if (block.type === "diff" && "path" in block) {
159
+ return `diff-${block.path}-${idx}`;
160
+ }
161
+ if (block.type === "terminal" && "terminalId" in block) {
162
+ return `terminal-${block.terminalId}`;
163
+ }
164
+ if (block.type === "text" && "text" in block) {
165
+ return `text-${block.text.substring(0, 20)}-${idx}`;
166
+ }
167
+ if (block.type === "content" && "content" in block) {
168
+ const innerContent = block.content;
169
+ return `content-${innerContent.text?.substring(0, 20)}-${idx}`;
170
+ }
171
+ return `block-${idx}`;
172
+ };
173
+ // Helper to render text content (with JSON parsing if applicable)
174
+ const renderTextContent = (text, key) => {
175
+ // Try to parse as JSON
176
+ try {
177
+ const parsed = JSON.parse(text);
178
+ // If it's an object or array, render with JsonView
179
+ if (typeof parsed === "object" && parsed !== null) {
180
+ return (_jsx("div", { className: "text-[11px]", children: _jsx(JsonView, { value: parsed, collapsed: false, displayDataTypes: false, displayObjectSize: false, enableClipboard: true, style: jsonStyle }) }, key));
181
+ }
182
+ }
183
+ catch {
184
+ // Not valid JSON, render as plain text
185
+ }
186
+ // Render as plain text
187
+ return (_jsx("pre", { className: "whitespace-pre-wrap font-mono text-[11px] text-foreground overflow-x-auto", children: text }, key));
188
+ };
189
+ // Handle nested content blocks (ACP format)
190
+ if (block.type === "content" && "content" in block) {
191
+ const innerContent = block.content;
192
+ if (innerContent.type === "text" && innerContent.text) {
193
+ return renderTextContent(innerContent.text, getBlockKey());
194
+ }
195
+ }
196
+ // Handle direct text blocks
197
+ if (block.type === "text" && "text" in block) {
198
+ return renderTextContent(block.text, getBlockKey());
199
+ }
200
+ // Handle image blocks
201
+ if (block.type === "image") {
202
+ const alt = block.alt || "Generated image";
203
+ let imageSrc;
204
+ if ("data" in block) {
205
+ // Base64 encoded image
206
+ const mimeType = block.mimeType || "image/png";
207
+ imageSrc = `data:${mimeType};base64,${block.data}`;
208
+ }
209
+ else if ("url" in block) {
210
+ // URL or file path
211
+ imageSrc = block.url;
212
+ }
213
+ else {
214
+ return null;
215
+ }
216
+ return (_jsx("div", { className: "my-2", children: _jsx("img", { src: imageSrc, alt: alt, className: "max-w-full h-auto rounded-md border border-border" }) }, getBlockKey()));
217
+ }
218
+ // Handle diff blocks
219
+ if (block.type === "diff" &&
220
+ "path" in block &&
221
+ "oldText" in block &&
222
+ "newText" in block) {
223
+ return (_jsxs("div", { className: "border border-border rounded bg-card", children: [_jsxs("div", { className: "bg-muted px-2 py-1 text-[10px] font-mono text-muted-foreground border-b border-border", children: [block.path, "line" in block &&
224
+ block.line !== null &&
225
+ block.line !== undefined &&
226
+ `:${block.line}`] }), _jsxs("div", { className: "p-2 font-mono text-[11px]", children: [_jsxs("div", { className: "text-red-500 dark:text-red-400", children: ["- ", block.oldText] }), _jsxs("div", { className: "text-green-500 dark:text-green-400", children: ["+ ", block.newText] })] })] }, getBlockKey()));
227
+ }
228
+ // Handle terminal blocks
229
+ if (block.type === "terminal" && "terminalId" in block) {
230
+ return (_jsxs("div", { className: "bg-neutral-900 text-neutral-100 p-2 rounded text-[11px] font-mono", children: ["Terminal: ", block.terminalId] }, getBlockKey()));
231
+ }
232
+ return null;
233
+ }), toolCall.error && (_jsxs("div", { className: "text-destructive font-mono text-[11px] mt-2", children: ["Error: ", toolCall.error] }))] })] })) : null, toolCall._meta?.truncationWarning && (_jsxs("div", { className: "mx-3 mt-3 mb-0 flex items-center gap-2 rounded-md bg-yellow-50 dark:bg-yellow-950/20 px-3 py-2 text-[11px] text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-900", children: [_jsx("span", { className: "text-yellow-600 dark:text-yellow-500", children: "\u26A0\uFE0F" }), _jsx("span", { children: toolCall._meta.truncationWarning })] })), (toolCall.tokenUsage || toolCall.startedAt) && (_jsxs("div", { className: "p-2 bg-muted/50 border-t border-border flex flex-wrap gap-4 text-[10px] text-muted-foreground font-sans", children: [toolCall.tokenUsage && (_jsxs("div", { className: "flex gap-3", children: [toolCall.tokenUsage.inputTokens !== undefined && (_jsxs("div", { children: [_jsx("span", { className: "uppercase tracking-wide font-semibold mr-1", children: "Input:" }), toolCall.tokenUsage.inputTokens.toLocaleString()] })), toolCall.tokenUsage.outputTokens !== undefined && (_jsxs("div", { children: [_jsx("span", { className: "uppercase tracking-wide font-semibold mr-1", children: "Output:" }), toolCall.tokenUsage.outputTokens.toLocaleString()] })), toolCall.tokenUsage.totalTokens !== undefined && (_jsxs("div", { children: [_jsx("span", { className: "uppercase tracking-wide font-semibold mr-1", children: "Total:" }), toolCall.tokenUsage.totalTokens.toLocaleString()] }))] })), toolCall.startedAt && (_jsxs("div", { className: "flex gap-3 ml-auto", children: [_jsxs("span", { children: ["Started: ", new Date(toolCall.startedAt).toLocaleTimeString()] }), toolCall.completedAt && (_jsxs("span", { children: ["Completed:", " ", new Date(toolCall.completedAt).toLocaleTimeString(), " (", Math.round((toolCall.completedAt - toolCall.startedAt) / 1000), "s)"] }))] }))] }))] }))] }));
234
+ }
@@ -0,0 +1,8 @@
1
+ import type { ToolCall as ToolCallType } from "../../core/schemas/tool-call.js";
2
+ export interface ToolCallGroupProps {
3
+ toolCalls: ToolCallType[];
4
+ }
5
+ /**
6
+ * ToolCallGroup component - displays a group of parallel tool calls with collapsible details
7
+ */
8
+ export declare function ToolCallGroup({ toolCalls }: ToolCallGroupProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDown, ListVideo } from "lucide-react";
3
+ import React, { useState } from "react";
4
+ import { ToolCall } from "./ToolCall.js";
5
+ /**
6
+ * ToolCallGroup component - displays a group of parallel tool calls with collapsible details
7
+ */
8
+ export function ToolCallGroup({ toolCalls }) {
9
+ const [isExpanded, setIsExpanded] = useState(false);
10
+ // Calculate group status based on individual tool call statuses
11
+ const getGroupStatus = () => {
12
+ const statuses = toolCalls.map((tc) => tc.status);
13
+ if (statuses.some((s) => s === "failed"))
14
+ return "failed";
15
+ if (statuses.some((s) => s === "in_progress"))
16
+ return "in_progress";
17
+ if (statuses.every((s) => s === "completed"))
18
+ return "completed";
19
+ return "pending";
20
+ };
21
+ const groupStatus = getGroupStatus();
22
+ // Generate summary of tool names
23
+ const toolNames = toolCalls.map((tc) => tc.prettyName || tc.title);
24
+ const uniqueNames = [...new Set(toolNames)];
25
+ const summary = uniqueNames.length <= 2
26
+ ? uniqueNames.join(", ")
27
+ : `${uniqueNames.slice(0, 2).join(", ")} +${uniqueNames.length - 2} more`;
28
+ return (_jsxs("div", { className: "flex flex-col my-4", 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: "text-muted-foreground", children: _jsx(ListVideo, { className: "h-3 w-3" }) }), _jsx("span", { className: "text-paragraph-sm text-muted-foreground", children: "Parallel operation" }), _jsx("span", { className: "text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground/70", children: toolCalls.length }), _jsx(ChevronDown, { className: `h-3 w-3 text-muted-foreground/70 transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}` })] }), !isExpanded && (_jsx("span", { className: "text-paragraph-sm text-muted-foreground/70 pl-4.5", children: summary }))] }), isExpanded && (_jsx("div", { className: "mt-1", children: toolCalls.map((toolCall) => (_jsxs("div", { className: "flex items-start", children: [_jsx("div", { className: "w-2.5 h-4 border-l-2 border-b-2 border-border rounded-bl-[6px] mt-1 mr-0.5 shrink-0" }), _jsx("div", { className: "flex-1 -mt-2", children: _jsx(ToolCall, { toolCall: toolCall }) })] }, toolCall.id))) }))] }));
29
+ }
@@ -8,6 +8,7 @@ import { generateSmartSummary } from "../../core/utils/tool-summary.js";
8
8
  import { expandCollapseVariants, fadeInVariants, getDuration, getTransition, motionDuration, motionEasing, rotateVariants, shimmerTransition, standardTransition, } from "../lib/motion.js";
9
9
  import { cn } from "../lib/utils.js";
10
10
  import * as ChatLayout from "./ChatLayout.js";
11
+ import { SubAgentDetails } from "./SubAgentDetails.js";
11
12
  import { useTheme } from "./ThemeProvider.js";
12
13
  /**
13
14
  * Map of icon names to Lucide components
@@ -38,6 +39,14 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
38
39
  // For single tool call, extract it
39
40
  const singleToolCall = toolCalls.length === 1 ? toolCalls[0] : null;
40
41
  const isTodoWrite = singleToolCall?.title === "todo_write";
42
+ // Detect subagent calls
43
+ const hasLiveSubagent = !!(singleToolCall?.subagentPort && singleToolCall?.subagentSessionId);
44
+ const hasStoredSubagent = !!(singleToolCall?.subagentMessages &&
45
+ singleToolCall.subagentMessages.length > 0);
46
+ const isSubagentCall = hasLiveSubagent || hasStoredSubagent;
47
+ const isReplaySubagent = hasStoredSubagent;
48
+ // State for subagent expansion
49
+ const [isSubagentExpanded, setIsSubagentExpanded] = useState(false);
41
50
  // Safely access ChatLayout context
42
51
  const layoutContext = React.useContext(ChatLayout.Context);
43
52
  // Determine display state
@@ -75,6 +84,11 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
75
84
  layoutContext.setActiveTab("todo");
76
85
  }
77
86
  }
87
+ else if (isSubagentCall) {
88
+ // Toggle subagent expansion
89
+ setUserInteracted(true);
90
+ setIsSubagentExpanded(!isSubagentExpanded);
91
+ }
78
92
  else {
79
93
  // Normal expand/collapse
80
94
  setUserInteracted(true); // Mark as user-interacted to prevent auto-minimize
@@ -83,7 +97,15 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
83
97
  setIsMinimized(false);
84
98
  }
85
99
  }
86
- }, [isTodoWrite, layoutContext, isExpanded, isMinimized, singleToolCall]);
100
+ }, [
101
+ isTodoWrite,
102
+ layoutContext,
103
+ isExpanded,
104
+ isMinimized,
105
+ singleToolCall,
106
+ isSubagentCall,
107
+ isSubagentExpanded,
108
+ ]);
87
109
  // Get icon for display
88
110
  const getIcon = () => {
89
111
  if (isGrouped) {
@@ -166,15 +188,33 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
166
188
  }, transition: getTransition(shouldReduceMotion ?? false, {
167
189
  duration: motionDuration.normal,
168
190
  ease: motionEasing.smooth,
169
- }), className: "h-3 w-3 text-text-secondary/70 group-hover:text-text-secondary transition-colors", children: _jsx(ChevronDown, { className: "h-3 w-3" }) }))] }), !isGrouped && singleToolCall?.subline && (_jsx("span", { className: "text-paragraph-sm text-text-secondary/70 pl-4.5", children: singleToolCall.subline })), isGrouped && !isExpanded && (_jsx("span", { className: "text-paragraph-sm text-text-secondary/70 pl-4.5", children: displayText }))] }), _jsx(AnimatePresence, { initial: false, children: !isTodoWrite && isExpanded && (_jsx(motion.div, { initial: "collapsed", animate: "expanded", exit: "collapsed", variants: expandCollapseVariants, transition: getTransition(shouldReduceMotion ?? false, {
191
+ }), className: "h-3 w-3 text-text-secondary/70 group-hover:text-text-secondary transition-colors", children: _jsx(ChevronDown, { className: "h-3 w-3" }) }))] }), !isGrouped && singleToolCall?.subline && (_jsx("span", { className: "text-paragraph-sm text-text-secondary/70 pl-4.5", children: singleToolCall.subline })), isGrouped && !isExpanded && (_jsx("span", { className: "text-paragraph-sm text-text-secondary/70 pl-4.5", children: displayText }))] }), !isTodoWrite && isSubagentCall && singleToolCall && (_jsx("div", { className: "pl-4.5 mt-2", children: _jsx(SubAgentDetails, { port: singleToolCall.subagentPort, sessionId: singleToolCall.subagentSessionId, parentStatus: singleToolCall.status, agentName: singleToolCall.rawInput?.agentName, query: singleToolCall.rawInput?.query, isExpanded: isSubagentExpanded, onExpandChange: setIsSubagentExpanded, storedMessages: singleToolCall.subagentMessages, isReplay: isReplaySubagent }) })), _jsx(AnimatePresence, { initial: false, children: !isTodoWrite && isExpanded && (_jsx(motion.div, { initial: "collapsed", animate: "expanded", exit: "collapsed", variants: expandCollapseVariants, transition: getTransition(shouldReduceMotion ?? false, {
170
192
  duration: motionDuration.normal,
171
193
  ease: motionEasing.smooth,
172
194
  }), className: "mt-1", children: isGrouped ? (
173
195
  // Render individual tool calls in group
174
- _jsx("div", { className: "flex flex-col gap-4 mt-2", children: toolCalls.map((toolCall) => (_jsx("div", { className: "flex items-start gap-1.5", children: _jsx("div", { className: "flex-1 ml-5", children: _jsx(ToolOperationDetails, { toolCall: toolCall }) }) }, toolCall.id))) })) : (
196
+ _jsx("div", { className: "flex flex-col gap-4 mt-2", children: toolCalls.map((toolCall) => (_jsx(GroupedToolCallItem, { toolCall: toolCall }, toolCall.id))) })) : (
175
197
  // Render single tool call details
176
198
  singleToolCall && (_jsx(ToolOperationDetails, { toolCall: singleToolCall }))) }, "expanded-content")) })] }));
177
199
  }
200
+ /**
201
+ * Component to render a single tool call within a grouped parallel operation
202
+ * Handles subagent calls with their own expansion state
203
+ */
204
+ function GroupedToolCallItem({ toolCall }) {
205
+ const [isSubagentExpanded, setIsSubagentExpanded] = useState(false);
206
+ // Detect subagent calls
207
+ const hasLiveSubagent = !!(toolCall.subagentPort && toolCall.subagentSessionId);
208
+ const hasStoredSubagent = !!(toolCall.subagentMessages && toolCall.subagentMessages.length > 0);
209
+ const isSubagentCall = hasLiveSubagent || hasStoredSubagent;
210
+ const isReplaySubagent = hasStoredSubagent;
211
+ if (isSubagentCall) {
212
+ // Render subagent with clickable header and SubAgentDetails component
213
+ return (_jsxs("div", { className: "flex flex-col ml-5", children: [_jsxs("button", { type: "button", className: "flex items-center gap-1.5 cursor-pointer bg-transparent border-none p-0 text-left group w-fit", onClick: () => setIsSubagentExpanded(!isSubagentExpanded), "aria-expanded": isSubagentExpanded, children: [_jsx("div", { className: "text-text-secondary/70 group-hover:text-text-secondary transition-colors", children: _jsx(CircleDot, { className: "h-3 w-3" }) }), _jsx("span", { className: "text-paragraph-sm text-text-secondary/70 group-hover:text-text-secondary transition-colors", children: toolCall.rawInput?.agentName || "Subagent" }), _jsx(ChevronDown, { className: `h-3 w-3 text-text-secondary/70 group-hover:text-text-secondary transition-colors transition-transform duration-200 ${isSubagentExpanded ? "rotate-180" : ""}` })] }), _jsx("div", { className: "pl-4.5", children: _jsx(SubAgentDetails, { port: toolCall.subagentPort, sessionId: toolCall.subagentSessionId, parentStatus: toolCall.status, agentName: toolCall.rawInput?.agentName, query: toolCall.rawInput?.query, isExpanded: isSubagentExpanded, onExpandChange: setIsSubagentExpanded, storedMessages: toolCall.subagentMessages, isReplay: isReplaySubagent }) })] }));
214
+ }
215
+ // Regular tool call - show details
216
+ return (_jsx("div", { className: "flex items-start gap-1.5", children: _jsx("div", { className: "flex-1 ml-5", children: _jsx(ToolOperationDetails, { toolCall: toolCall }) }) }));
217
+ }
178
218
  /**
179
219
  * Component to display detailed tool call information
180
220
  */
@@ -13,9 +13,9 @@ export type MessageRole = z.infer<typeof MessageRole>;
13
13
  * Content type for messages
14
14
  */
15
15
  export declare const ContentType: z.ZodEnum<{
16
- file: "file";
17
16
  text: "text";
18
17
  image: "image";
18
+ file: "file";
19
19
  tool_call: "tool_call";
20
20
  tool_result: "tool_result";
21
21
  }>;
@@ -25,9 +25,9 @@ export type ContentType = z.infer<typeof ContentType>;
25
25
  */
26
26
  export declare const BaseContent: z.ZodObject<{
27
27
  type: z.ZodEnum<{
28
- file: "file";
29
28
  text: "text";
30
29
  image: "image";
30
+ file: "file";
31
31
  tool_call: "tool_call";
32
32
  tool_result: "tool_result";
33
33
  }>;
@@ -4,10 +4,10 @@ import { z } from "zod";
4
4
  */
5
5
  export declare const SessionStatus: z.ZodEnum<{
6
6
  error: "error";
7
- active: "active";
8
7
  idle: "idle";
9
8
  connecting: "connecting";
10
9
  connected: "connected";
10
+ active: "active";
11
11
  streaming: "streaming";
12
12
  disconnected: "disconnected";
13
13
  }>;
@@ -41,10 +41,10 @@ export declare const Session: z.ZodObject<{
41
41
  id: z.ZodString;
42
42
  status: z.ZodEnum<{
43
43
  error: "error";
44
- active: "active";
45
44
  idle: "idle";
46
45
  connecting: "connecting";
47
46
  connected: "connected";
47
+ active: "active";
48
48
  streaming: "streaming";
49
49
  disconnected: "disconnected";
50
50
  }>;
@@ -117,10 +117,10 @@ export declare const SessionUpdate: z.ZodUnion<readonly [z.ZodObject<{
117
117
  sessionId: z.ZodString;
118
118
  status: z.ZodOptional<z.ZodEnum<{
119
119
  error: "error";
120
- active: "active";
121
120
  idle: "idle";
122
121
  connecting: "connecting";
123
122
  connected: "connected";
123
+ active: "active";
124
124
  streaming: "streaming";
125
125
  disconnected: "disconnected";
126
126
  }>>;
@@ -354,10 +354,10 @@ export declare const SessionUpdate: z.ZodUnion<readonly [z.ZodObject<{
354
354
  sessionId: z.ZodString;
355
355
  status: z.ZodOptional<z.ZodEnum<{
356
356
  error: "error";
357
- active: "active";
358
357
  idle: "idle";
359
358
  connecting: "connecting";
360
359
  connected: "connected";
360
+ active: "active";
361
361
  streaming: "streaming";
362
362
  disconnected: "disconnected";
363
363
  }>>;
@@ -561,10 +561,10 @@ export declare const SessionUpdate: z.ZodUnion<readonly [z.ZodObject<{
561
561
  sessionId: z.ZodString;
562
562
  status: z.ZodOptional<z.ZodEnum<{
563
563
  error: "error";
564
- active: "active";
565
564
  idle: "idle";
566
565
  connecting: "connecting";
567
566
  connected: "connected";
567
+ active: "active";
568
568
  streaming: "streaming";
569
569
  disconnected: "disconnected";
570
570
  }>>;
@@ -626,10 +626,10 @@ export declare const SessionUpdate: z.ZodUnion<readonly [z.ZodObject<{
626
626
  sessionId: z.ZodString;
627
627
  status: z.ZodOptional<z.ZodEnum<{
628
628
  error: "error";
629
- active: "active";
630
629
  idle: "idle";
631
630
  connecting: "connecting";
632
631
  connected: "connected";
632
+ active: "active";
633
633
  streaming: "streaming";
634
634
  disconnected: "disconnected";
635
635
  }>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -49,7 +49,7 @@
49
49
  "@radix-ui/react-slot": "^1.2.4",
50
50
  "@radix-ui/react-tabs": "^1.1.13",
51
51
  "@radix-ui/react-tooltip": "^1.2.8",
52
- "@townco/core": "0.0.47",
52
+ "@townco/core": "0.0.48",
53
53
  "@uiw/react-json-view": "^2.0.0-alpha.39",
54
54
  "bun": "^1.3.1",
55
55
  "class-variance-authority": "^0.7.1",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@tailwindcss/postcss": "^4.1.17",
70
- "@townco/tsconfig": "0.1.66",
70
+ "@townco/tsconfig": "0.1.67",
71
71
  "@types/node": "^24.10.0",
72
72
  "@types/react": "^19.2.2",
73
73
  "ink": "^6.4.0",