@townco/ui 0.1.103 → 0.1.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Browser console capture and server sync
3
+ * Intercepts console methods and sends logs to the agent server
4
+ * Follows the pattern from packages/ui/src/sdk/transports/stdio.ts
5
+ */
6
+ /**
7
+ * Initialize browser logger
8
+ * Must be called early, before other code that might log
9
+ */
10
+ export declare function initBrowserLogger(agentServerUrl: string): void;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Browser console capture and server sync
3
+ * Intercepts console methods and sends logs to the agent server
4
+ * Follows the pattern from packages/ui/src/sdk/transports/stdio.ts
5
+ */
6
+ import { createLogger, subscribeToLogs } from "@townco/core";
7
+ let initialized = false;
8
+ let serverUrl = null;
9
+ const logQueue = [];
10
+ let flushTimeoutId = null;
11
+ // Store original console methods to preserve DevTools output
12
+ const originalConsole = {
13
+ log: console.log.bind(console),
14
+ error: console.error.bind(console),
15
+ warn: console.warn.bind(console),
16
+ info: console.info.bind(console),
17
+ debug: console.debug.bind(console),
18
+ };
19
+ const logger = createLogger("gui-console");
20
+ /**
21
+ * Safely stringify arguments for logging
22
+ */
23
+ function stringifyArgs(args) {
24
+ return args
25
+ .map((arg) => {
26
+ if (typeof arg === "string")
27
+ return arg;
28
+ if (arg instanceof Error)
29
+ return `${arg.name}: ${arg.message}`;
30
+ try {
31
+ return JSON.stringify(arg);
32
+ }
33
+ catch {
34
+ return String(arg);
35
+ }
36
+ })
37
+ .join(" ");
38
+ }
39
+ /**
40
+ * Queue a log entry to be sent to the server
41
+ */
42
+ function queueLog(entry) {
43
+ logQueue.push(entry);
44
+ // Immediate flush for errors
45
+ if (entry.level === "error" || entry.level === "fatal") {
46
+ flushLogs();
47
+ return;
48
+ }
49
+ // Debounced flush for other levels
50
+ if (!flushTimeoutId) {
51
+ flushTimeoutId = setTimeout(() => {
52
+ flushTimeoutId = null;
53
+ flushLogs();
54
+ }, 500);
55
+ }
56
+ }
57
+ /**
58
+ * Send queued logs to the server
59
+ */
60
+ async function flushLogs() {
61
+ if (logQueue.length === 0 || !serverUrl)
62
+ return;
63
+ const logs = logQueue.splice(0);
64
+ try {
65
+ await fetch(`${serverUrl}/logs/browser`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ logs }),
69
+ });
70
+ }
71
+ catch {
72
+ // Silent fail - don't break the app if server is unavailable
73
+ }
74
+ }
75
+ /**
76
+ * Initialize browser logger
77
+ * Must be called early, before other code that might log
78
+ */
79
+ export function initBrowserLogger(agentServerUrl) {
80
+ if (initialized)
81
+ return;
82
+ initialized = true;
83
+ serverUrl = agentServerUrl;
84
+ // Override console methods - preserve original output, then log
85
+ console.log = (...args) => {
86
+ originalConsole.log(...args);
87
+ logger.info(stringifyArgs(args));
88
+ };
89
+ console.info = (...args) => {
90
+ originalConsole.info(...args);
91
+ logger.info(stringifyArgs(args));
92
+ };
93
+ console.warn = (...args) => {
94
+ originalConsole.warn(...args);
95
+ logger.warn(stringifyArgs(args));
96
+ };
97
+ console.error = (...args) => {
98
+ originalConsole.error(...args);
99
+ logger.error(stringifyArgs(args));
100
+ };
101
+ console.debug = (...args) => {
102
+ originalConsole.debug(...args);
103
+ logger.debug(stringifyArgs(args));
104
+ };
105
+ // Capture uncaught exceptions
106
+ window.onerror = (message, source, line, col, error) => {
107
+ logger.error(String(message), {
108
+ source,
109
+ line,
110
+ col,
111
+ stack: error?.stack,
112
+ });
113
+ return false; // Let default handler run too
114
+ };
115
+ // Capture unhandled promise rejections
116
+ window.onunhandledrejection = (event) => {
117
+ const reason = event.reason instanceof Error
118
+ ? `${event.reason.name}: ${event.reason.message}`
119
+ : String(event.reason);
120
+ logger.error(`Unhandled rejection: ${reason}`, {
121
+ stack: event.reason?.stack,
122
+ });
123
+ };
124
+ // Subscribe to logs and queue for server sync
125
+ subscribeToLogs((entry) => {
126
+ // Only sync gui-console logs to avoid duplicates
127
+ if (entry.service === "gui-console") {
128
+ queueLog(entry);
129
+ }
130
+ });
131
+ // Flush logs before page unload
132
+ window.addEventListener("beforeunload", () => {
133
+ if (flushTimeoutId) {
134
+ clearTimeout(flushTimeoutId);
135
+ }
136
+ // Use sendBeacon for reliable delivery on page unload
137
+ if (logQueue.length > 0 && serverUrl) {
138
+ navigator.sendBeacon(`${serverUrl}/logs/browser`, JSON.stringify({ logs: logQueue }));
139
+ }
140
+ });
141
+ }
@@ -7,5 +7,4 @@ export * from "./use-chat-messages.js";
7
7
  export * from "./use-chat-session.js";
8
8
  export * from "./use-media-query.js";
9
9
  export * from "./use-message-history.js";
10
- export * from "./use-subagent-stream.js";
11
10
  export * from "./use-tool-calls.js";
@@ -7,5 +7,4 @@ export * from "./use-chat-messages.js";
7
7
  export * from "./use-chat-session.js";
8
8
  export * from "./use-media-query.js";
9
9
  export * from "./use-message-history.js";
10
- export * from "./use-subagent-stream.js";
11
10
  export * from "./use-tool-calls.js";
@@ -29,10 +29,21 @@ export function useToolCalls(client) {
29
29
  }
30
30
  else if (update.type === "tool_call_update") {
31
31
  // Tool call update notification
32
+ _logger.info("[SUBAGENT] Frontend received tool_call_update", {
33
+ sessionId: update.sessionId,
34
+ toolCallId: update.toolCallUpdate.id,
35
+ status: update.toolCallUpdate.status,
36
+ hasSubagentMessages: !!update.toolCallUpdate.subagentMessages,
37
+ subagentMessageCount: update.toolCallUpdate.subagentMessages?.length || 0,
38
+ });
32
39
  // Update session-level tool calls (for sidebar)
33
40
  updateToolCall(update.sessionId, update.toolCallUpdate);
34
41
  // Also update in current assistant message (for inline display)
35
42
  updateToolCallInCurrentMessage(update.toolCallUpdate);
43
+ _logger.info("[SUBAGENT] Successfully updated tool call state", {
44
+ sessionId: update.sessionId,
45
+ toolCallId: update.toolCallUpdate.id,
46
+ });
36
47
  }
37
48
  });
38
49
  return () => {
@@ -3,6 +3,7 @@ import { createLogger } from "@townco/core";
3
3
  import { ArrowUp, Bug, Moon, PanelRight, Settings, Sun, X } from "lucide-react";
4
4
  import * as React from "react";
5
5
  import { useEffect, useState } from "react";
6
+ import { initBrowserLogger } from "../../browser-logger.js";
6
7
  import { useChatMessages, useChatSession, useToolCalls, } from "../../core/hooks/index.js";
7
8
  import { selectTodosForCurrentSession, useChatStore, } from "../../core/store/chat-store.js";
8
9
  import { useDocumentTitle } from "../hooks/use-favicon.js";
@@ -119,6 +120,15 @@ export function ChatView({ client, initialSessionId, error: initError, debuggerU
119
120
  }, [messages]);
120
121
  // Update document title with agent name and animated dots when streaming
121
122
  useDocumentTitle(agentName);
123
+ // Initialize browser logger to capture console output and send to server
124
+ useEffect(() => {
125
+ if (client) {
126
+ const baseUrl = client.getBaseUrl();
127
+ if (baseUrl) {
128
+ initBrowserLogger(baseUrl);
129
+ }
130
+ }
131
+ }, [client]);
122
132
  // Log connection status changes
123
133
  useEffect(() => {
124
134
  logger.debug("Connection status changed", { status: connectionStatus });
@@ -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
+ }
@@ -1,11 +1,5 @@
1
1
  import type { ToolCall as ToolCallType } from "../../core/schemas/tool-call.js";
2
2
  export interface SubAgentDetailsProps {
3
- /** Sub-agent HTTP port (required for live streaming, not for replay) */
4
- port?: number | undefined;
5
- /** Sub-agent session ID (required for live streaming, not for replay) */
6
- sessionId?: string | undefined;
7
- /** Optional host (defaults to localhost) */
8
- host?: string;
9
3
  /** Parent tool call status - use this to determine if sub-agent is running */
10
4
  parentStatus?: "pending" | "in_progress" | "completed" | "failed";
11
5
  /** Sub-agent name (for display) */
@@ -16,19 +10,16 @@ export interface SubAgentDetailsProps {
16
10
  isExpanded?: boolean;
17
11
  /** Callback when expand state changes */
18
12
  onExpandChange?: (expanded: boolean) => void;
19
- /** Stored messages for replay (if provided, SSE streaming is skipped) */
13
+ /** Messages from the subagent (streamed via parent tool_call_update) */
20
14
  storedMessages?: ToolCallType["subagentMessages"];
21
- /** Whether this is a replay (skips SSE connection) */
22
- isReplay?: boolean | undefined;
23
15
  }
24
16
  /**
25
17
  * SubAgentDetails component - displays streaming content from a sub-agent.
26
18
  *
27
19
  * This component:
28
- * - Connects directly to the sub-agent's SSE endpoint (live mode)
29
- * - Or displays stored messages (replay mode)
20
+ * - Displays messages from the subagent (received via parent tool_call_update events)
30
21
  * - Displays streaming text and tool calls
31
22
  * - Renders content as markdown
32
23
  * - Renders in a collapsible section (collapsed by default)
33
24
  */
34
- export declare function SubAgentDetails({ port, sessionId, host, parentStatus, agentName, query, isExpanded: controlledIsExpanded, onExpandChange, storedMessages, isReplay }: SubAgentDetailsProps): import("react/jsx-runtime").JSX.Element;
25
+ export declare function SubAgentDetails({ parentStatus, agentName, query, isExpanded: controlledIsExpanded, onExpandChange, storedMessages }: SubAgentDetailsProps): import("react/jsx-runtime").JSX.Element;
@@ -1,20 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ChevronDown, Loader2 } from "lucide-react";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { useSubagentStream } from "../../core/hooks/use-subagent-stream.js";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
5
4
  import { MarkdownRenderer } from "./MarkdownRenderer.js";
6
5
  const SCROLL_THRESHOLD = 50; // px from bottom to consider "at bottom"
7
6
  /**
8
7
  * SubAgentDetails component - displays streaming content from a sub-agent.
9
8
  *
10
9
  * This component:
11
- * - Connects directly to the sub-agent's SSE endpoint (live mode)
12
- * - Or displays stored messages (replay mode)
10
+ * - Displays messages from the subagent (received via parent tool_call_update events)
13
11
  * - Displays streaming text and tool calls
14
12
  * - Renders content as markdown
15
13
  * - Renders in a collapsible section (collapsed by default)
16
14
  */
17
- export function SubAgentDetails({ port, sessionId, host, parentStatus, agentName, query, isExpanded: controlledIsExpanded, onExpandChange, storedMessages, isReplay = false, }) {
15
+ export function SubAgentDetails({ parentStatus, agentName, query, isExpanded: controlledIsExpanded, onExpandChange, storedMessages, }) {
18
16
  const [internalIsExpanded, setInternalIsExpanded] = useState(false);
19
17
  // Use controlled state if provided, otherwise use internal state
20
18
  const isExpanded = controlledIsExpanded ?? internalIsExpanded;
@@ -23,31 +21,22 @@ export function SubAgentDetails({ port, sessionId, host, parentStatus, agentName
23
21
  const [isThinkingExpanded, setIsThinkingExpanded] = useState(true);
24
22
  const [isNearBottom, setIsNearBottom] = useState(true);
25
23
  const thinkingContainerRef = useRef(null);
26
- // Only use SSE streaming if not in replay mode and port/sessionId provided
27
- const shouldStream = !isReplay && port !== undefined && sessionId !== undefined;
28
- // Memoize stream options to prevent unnecessary reconnections
29
- const streamOptions = useMemo(() => shouldStream
30
- ? {
31
- // biome-ignore lint/style/noNonNullAssertion: shouldStream condition ensures port is defined
32
- port: port,
33
- // biome-ignore lint/style/noNonNullAssertion: shouldStream condition ensures sessionId is defined
34
- sessionId: sessionId,
35
- ...(host !== undefined ? { host } : {}),
36
- }
37
- : null, [shouldStream, port, sessionId, host]);
38
- const { messages: streamedMessages, isStreaming: hookIsStreaming, error, } = useSubagentStream(streamOptions);
39
- // Use stored messages if available, otherwise use streamed messages
40
- const messages = storedMessages && storedMessages.length > 0
41
- ? storedMessages
42
- : streamedMessages;
43
- // Use parent status as primary indicator, fall back to hook's streaming state
44
- // Parent is "in_progress" means sub-agent is definitely still running
45
- // In replay mode, we're never running
46
- const isRunning = isReplay
47
- ? false
48
- : parentStatus === "in_progress" ||
49
- parentStatus === "pending" ||
50
- hookIsStreaming;
24
+ // Use messages from storedMessages prop (populated by parent via tool_call_update)
25
+ const messages = storedMessages || [];
26
+ // Log when messages are received/updated
27
+ useEffect(() => {
28
+ console.log("[SUBAGENT] SubAgentDetails received messages update", {
29
+ agentName,
30
+ messageCount: messages.length,
31
+ parentStatus,
32
+ hasMessages: messages.length > 0,
33
+ contentPreview: messages[0]?.content?.substring(0, 100),
34
+ toolCallCount: messages[0]?.toolCalls?.length || 0,
35
+ contentBlockCount: messages[0]?.contentBlocks?.length || 0,
36
+ });
37
+ }, [messages, agentName, parentStatus]);
38
+ // Determine if subagent is still running based on parent status
39
+ const isRunning = parentStatus === "in_progress" || parentStatus === "pending";
51
40
  // Get the current/latest message
52
41
  const currentMessage = messages[messages.length - 1];
53
42
  const hasContent = currentMessage &&
@@ -55,6 +44,13 @@ export function SubAgentDetails({ port, sessionId, host, parentStatus, agentName
55
44
  (currentMessage.toolCalls && currentMessage.toolCalls.length > 0) ||
56
45
  (currentMessage.contentBlocks &&
57
46
  currentMessage.contentBlocks.length > 0));
47
+ console.log("[SUBAGENT] SubAgentDetails render state", {
48
+ agentName,
49
+ messageCount: messages.length,
50
+ hasContent,
51
+ isRunning,
52
+ parentStatus,
53
+ });
58
54
  // Auto-collapse Thinking when completed (so Output is the primary view)
59
55
  const prevIsRunningRef = useRef(isRunning);
60
56
  useEffect(() => {
@@ -125,7 +121,7 @@ export function SubAgentDetails({ port, sessionId, host, parentStatus, agentName
125
121
  ] })), _jsxs("div", { children: [
126
122
  _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: [
127
123
  _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" : ""}` })
128
- ] }), 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 &&
124
+ ] }), 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: [!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 &&
129
125
  currentMessage.contentBlocks.length > 0
130
126
  ? // Render interleaved content blocks
131
127
  currentMessage.contentBlocks.map((block, blockIdx) => 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-${block.text.slice(0, 30)}-${blockIdx}`)) : (_jsx(SubagentToolCallItem, { toolCall: block.toolCall }, block.toolCall.id)))
@@ -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,226 @@
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
+ const isSubagentCall = !!(toolCall.subagentPort && toolCall.subagentSessionId);
48
+ // Safely access ChatLayout context - will be undefined if not within ChatLayout
49
+ const layoutContext = React.useContext(ChatLayout.Context);
50
+ // Click handler: toggle sidepanel for TodoWrite, subagent details for subagents, expand for others
51
+ const handleHeaderClick = React.useCallback(() => {
52
+ if (isTodoWrite && layoutContext) {
53
+ // Toggle sidepanel - close if already open on todo tab, otherwise open
54
+ if (layoutContext.panelSize !== "hidden" &&
55
+ layoutContext.activeTab === "todo") {
56
+ layoutContext.setPanelSize("hidden");
57
+ }
58
+ else {
59
+ layoutContext.setPanelSize("small");
60
+ layoutContext.setActiveTab("todo");
61
+ }
62
+ }
63
+ else if (isSubagentCall) {
64
+ // Toggle subagent details
65
+ setIsSubagentExpanded(!isSubagentExpanded);
66
+ }
67
+ else {
68
+ // Normal expand/collapse
69
+ setIsExpanded(!isExpanded);
70
+ }
71
+ }, [
72
+ isTodoWrite,
73
+ layoutContext,
74
+ isExpanded,
75
+ isSubagentCall,
76
+ isSubagentExpanded,
77
+ ]);
78
+ // Determine which icon to show
79
+ const IconComponent = toolCall.icon && ICON_MAP[toolCall.icon]
80
+ ? ICON_MAP[toolCall.icon]
81
+ : CircleDot;
82
+ // Determine display name
83
+ const displayName = toolCall.prettyName || toolCall.title;
84
+ // Determine icon color based on status (especially for subagents)
85
+ const isSubagentRunning = isSubagentCall &&
86
+ (toolCall.status === "in_progress" || toolCall.status === "pending");
87
+ const isSubagentFailed = isSubagentCall && toolCall.status === "failed";
88
+ const iconColorClass = isSubagentCall
89
+ ? isSubagentFailed
90
+ ? "text-destructive"
91
+ : isSubagentRunning
92
+ ? "text-foreground animate-pulse"
93
+ : "text-green-500"
94
+ : "text-muted-foreground";
95
+ const statusTooltip = isSubagentCall
96
+ ? isSubagentFailed
97
+ ? "Sub-agent failed"
98
+ : isSubagentRunning
99
+ ? "Sub-agent running"
100
+ : "Sub-agent completed"
101
+ : undefined;
102
+ // Check if there's an error
103
+ const hasError = toolCall.status === "failed" || !!toolCall.error;
104
+ // Check if this is a preliminary (pending) tool call without full details yet
105
+ const isPreliminary = toolCall.status === "pending" &&
106
+ (!toolCall.rawInput || Object.keys(toolCall.rawInput).length === 0);
107
+ // JSON View style based on theme
108
+ const jsonStyle = {
109
+ fontSize: "11px",
110
+ backgroundColor: "transparent",
111
+ fontFamily: "inherit",
112
+ "--w-rjv-color": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
113
+ "--w-rjv-key-string": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
114
+ "--w-rjv-background-color": "transparent",
115
+ "--w-rjv-line-color": resolvedTheme === "dark" ? "#27272a" : "#e4e4e7",
116
+ "--w-rjv-arrow-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
117
+ "--w-rjv-edit-color": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
118
+ "--w-rjv-info-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
119
+ "--w-rjv-update-color": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
120
+ "--w-rjv-copied-color": resolvedTheme === "dark" ? "#fafafa" : "#09090b",
121
+ "--w-rjv-copied-success-color": resolvedTheme === "dark" ? "#22c55e" : "#16a34a",
122
+ "--w-rjv-curlybraces-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
123
+ "--w-rjv-colon-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
124
+ "--w-rjv-brackets-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
125
+ "--w-rjv-quotes-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
126
+ "--w-rjv-quotes-string-color": resolvedTheme === "dark" ? "#a1a1aa" : "#71717a",
127
+ "--w-rjv-type-string-color": resolvedTheme === "dark" ? "#22c55e" : "#16a34a",
128
+ "--w-rjv-type-int-color": resolvedTheme === "dark" ? "#f59e0b" : "#d97706",
129
+ "--w-rjv-type-float-color": resolvedTheme === "dark" ? "#f59e0b" : "#d97706",
130
+ "--w-rjv-type-bigint-color": resolvedTheme === "dark" ? "#f59e0b" : "#d97706",
131
+ "--w-rjv-type-boolean-color": resolvedTheme === "dark" ? "#3b82f6" : "#2563eb",
132
+ "--w-rjv-type-date-color": resolvedTheme === "dark" ? "#ec4899" : "#db2777",
133
+ "--w-rjv-type-url-color": resolvedTheme === "dark" ? "#3b82f6" : "#2563eb",
134
+ "--w-rjv-type-null-color": resolvedTheme === "dark" ? "#ef4444" : "#dc2626",
135
+ "--w-rjv-type-nan-color": resolvedTheme === "dark" ? "#ef4444" : "#dc2626",
136
+ "--w-rjv-type-undefined-color": resolvedTheme === "dark" ? "#ef4444" : "#dc2626",
137
+ };
138
+ // Preliminary tool calls show as simple light gray text without expansion
139
+ if (isPreliminary) {
140
+ return (_jsx("div", { className: "flex flex-col my-4", children: _jsxs("span", { className: "text-paragraph-sm text-muted-foreground/50", children: ["Invoking ", displayName] }) }));
141
+ }
142
+ 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 }) })), !isTodoWrite && !isSubagentCall && isExpanded && (_jsxs("div", { className: "mt-2 text-sm border border-border rounded-lg bg-card overflow-hidden w-full", children: [toolCall.rawInput &&
143
+ Object.keys(toolCall.rawInput).length > 0 &&
144
+ !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 &&
145
+ loc.line !== undefined &&
146
+ `:${loc.line}`] }, `${loc.path}:${loc.line ?? ""}`))) })] })), (toolCall.content && toolCall.content.length > 0) ||
147
+ 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) => {
148
+ // Generate a stable key based on content
149
+ const getBlockKey = () => {
150
+ if (block.type === "diff" && "path" in block) {
151
+ return `diff-${block.path}-${idx}`;
152
+ }
153
+ if (block.type === "terminal" && "terminalId" in block) {
154
+ return `terminal-${block.terminalId}`;
155
+ }
156
+ if (block.type === "text" && "text" in block) {
157
+ return `text-${block.text.substring(0, 20)}-${idx}`;
158
+ }
159
+ if (block.type === "content" && "content" in block) {
160
+ const innerContent = block.content;
161
+ return `content-${innerContent.text?.substring(0, 20)}-${idx}`;
162
+ }
163
+ return `block-${idx}`;
164
+ };
165
+ // Helper to render text content (with JSON parsing if applicable)
166
+ const renderTextContent = (text, key) => {
167
+ // Try to parse as JSON
168
+ try {
169
+ const parsed = JSON.parse(text);
170
+ // If it's an object or array, render with JsonView
171
+ if (typeof parsed === "object" && parsed !== null) {
172
+ return (_jsx("div", { className: "text-[11px]", children: _jsx(JsonView, { value: parsed, collapsed: false, displayDataTypes: false, displayObjectSize: false, enableClipboard: true, style: jsonStyle }) }, key));
173
+ }
174
+ }
175
+ catch {
176
+ // Not valid JSON, render as plain text
177
+ }
178
+ // Render as plain text
179
+ return (_jsx("pre", { className: "whitespace-pre-wrap font-mono text-[11px] text-foreground overflow-x-auto", children: text }, key));
180
+ };
181
+ // Handle nested content blocks (ACP format)
182
+ if (block.type === "content" && "content" in block) {
183
+ const innerContent = block.content;
184
+ if (innerContent.type === "text" && innerContent.text) {
185
+ return renderTextContent(innerContent.text, getBlockKey());
186
+ }
187
+ }
188
+ // Handle direct text blocks
189
+ if (block.type === "text" && "text" in block) {
190
+ return renderTextContent(block.text, getBlockKey());
191
+ }
192
+ // Handle image blocks
193
+ if (block.type === "image") {
194
+ const alt = block.alt || "Generated image";
195
+ let imageSrc;
196
+ if ("data" in block) {
197
+ // Base64 encoded image
198
+ const mimeType = block.mimeType || "image/png";
199
+ imageSrc = `data:${mimeType};base64,${block.data}`;
200
+ }
201
+ else if ("url" in block) {
202
+ // URL or file path
203
+ imageSrc = block.url;
204
+ }
205
+ else {
206
+ return null;
207
+ }
208
+ 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()));
209
+ }
210
+ // Handle diff blocks
211
+ if (block.type === "diff" &&
212
+ "path" in block &&
213
+ "oldText" in block &&
214
+ "newText" in block) {
215
+ 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 &&
216
+ block.line !== null &&
217
+ block.line !== undefined &&
218
+ `:${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()));
219
+ }
220
+ // Handle terminal blocks
221
+ if (block.type === "terminal" && "terminalId" in block) {
222
+ return (_jsxs("div", { className: "bg-neutral-900 text-neutral-100 p-2 rounded text-[11px] font-mono", children: ["Terminal: ", block.terminalId] }, getBlockKey()));
223
+ }
224
+ return null;
225
+ }), 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)"] }))] }))] }))] }))] }));
226
+ }
@@ -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
+ }
@@ -138,12 +138,9 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
138
138
  (n.metadata?.action === "truncated" ||
139
139
  n.metadata?.action === "compacted_then_truncated")))
140
140
  : false;
141
- // Detect subagent calls
142
- const hasLiveSubagent = !!(singleToolCall?.subagentPort && singleToolCall?.subagentSessionId);
143
- const hasStoredSubagent = !!(singleToolCall?.subagentMessages &&
141
+ // Detect subagent calls (subagents now run in-process, messages in subagentMessages)
142
+ const isSubagentCall = !!(singleToolCall?.subagentMessages &&
144
143
  singleToolCall.subagentMessages.length > 0);
145
- const isSubagentCall = hasLiveSubagent || hasStoredSubagent;
146
- const isReplaySubagent = hasStoredSubagent;
147
144
  // State for subagent expansion
148
145
  const [isSubagentExpanded, setIsSubagentExpanded] = useState(false);
149
146
  // Safely access ChatLayout context
@@ -312,7 +309,7 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
312
309
  return (_jsxs("span", { className: "text-paragraph-sm text-text-secondary/70 pl-4.5", children: [compactedCount, " response", compactedCount > 1 ? "s" : "", " ", "compacted"] }));
313
310
  }
314
311
  return null;
315
- })()] }), !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 }) })), !isTodoWrite && isExpanded && (_jsx("div", { className: "mt-1", children: isGrouped ? (
312
+ })()] }), !isTodoWrite && isSubagentCall && singleToolCall && (_jsx("div", { className: "pl-4.5 mt-2", children: _jsx(SubAgentDetails, { parentStatus: singleToolCall.status, agentName: singleToolCall.rawInput?.agentName, query: singleToolCall.rawInput?.query, isExpanded: isSubagentExpanded, onExpandChange: setIsSubagentExpanded, storedMessages: singleToolCall.subagentMessages }) })), !isTodoWrite && isExpanded && (_jsx("div", { className: "mt-1", children: isGrouped ? (
316
313
  // Render individual tool calls in group
317
314
  _jsx("div", { className: "flex flex-col gap-4 mt-2", children: toolCalls.map((toolCall) => {
318
315
  const hookNotification = hookNotifications.find((n) => n.toolCallId === toolCall.id);
@@ -329,11 +326,8 @@ export function ToolOperation({ toolCalls, isGrouped = false, autoMinimize = tru
329
326
  */
330
327
  function GroupedToolCallItem({ toolCall, hookNotification, }) {
331
328
  const [isExpanded, setIsExpanded] = useState(false);
332
- // Detect subagent calls
333
- const hasLiveSubagent = !!(toolCall.subagentPort && toolCall.subagentSessionId);
334
- const hasStoredSubagent = !!(toolCall.subagentMessages && toolCall.subagentMessages.length > 0);
335
- const isSubagentCall = hasLiveSubagent || hasStoredSubagent;
336
- const isReplaySubagent = hasStoredSubagent;
329
+ // Detect subagent calls (subagents now run in-process, messages in subagentMessages)
330
+ const isSubagentCall = !!(toolCall.subagentMessages && toolCall.subagentMessages.length > 0);
337
331
  // Detect compaction for this individual tool call
338
332
  const hasCompaction = !!((hookNotification?.status === "completed" &&
339
333
  hookNotification.metadata?.action &&
@@ -365,7 +359,7 @@ function GroupedToolCallItem({ toolCall, hookNotification, }) {
365
359
  : "Response was compacted";
366
360
  })() })
367
361
  ] }) })), _jsx(ChevronDown, { className: `h-3 w-3 text-text-secondary/70 group-hover:text-text-secondary transition-colors transition-transform duration-200 ${isExpanded ? "rotate-180" : ""}` })
368
- ] }), _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: isExpanded, onExpandChange: setIsExpanded, storedMessages: toolCall.subagentMessages, isReplay: isReplaySubagent }) })
362
+ ] }), _jsx("div", { className: "pl-4.5", children: _jsx(SubAgentDetails, { parentStatus: toolCall.status, agentName: toolCall.rawInput?.agentName, query: toolCall.rawInput?.query, isExpanded: isExpanded, onExpandChange: setIsExpanded, storedMessages: toolCall.subagentMessages }) })
369
363
  ] }));
370
364
  }
371
365
  // Regular tool call - collapsible with clickable header
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.103",
3
+ "version": "0.1.105",
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.81",
52
+ "@townco/core": "0.0.83",
53
53
  "@types/mdast": "^4.0.4",
54
54
  "@uiw/react-json-view": "^2.0.0-alpha.39",
55
55
  "class-variance-authority": "^0.7.1",
@@ -67,7 +67,7 @@
67
67
  "zustand": "^5.0.8"
68
68
  },
69
69
  "devDependencies": {
70
- "@townco/tsconfig": "0.1.100",
70
+ "@townco/tsconfig": "0.1.102",
71
71
  "@types/node": "^24.10.0",
72
72
  "@types/react": "^19.2.2",
73
73
  "@types/unist": "^3.0.3",