From 0207a1f3a3c87f1c6c1aee8213be999b23289386 Mon Sep 17 00:00:00 2001 From: viper151 Date: Thu, 19 Feb 2026 17:32:45 +0100 Subject: [PATCH] Feat: subagent tool grouping (#398) * fix(mobile): prevent bottom padding removal on input focus * fix: change subagent rendering * fix: subagent task name --- server/claude-sdk.js | 8 +- server/projects.js | 112 +++++++++-- src/components/app/AppContent.tsx | 2 +- .../chat/hooks/useChatComposerState.ts | 4 + .../chat/hooks/useChatRealtimeHandlers.ts | 74 ++++++- src/components/chat/tools/ToolRenderer.tsx | 27 ++- .../tools/components/CollapsibleSection.tsx | 2 +- .../tools/components/SubagentContainer.tsx | 180 ++++++++++++++++++ src/components/chat/tools/components/index.ts | 1 + .../chat/tools/configs/toolConfigs.ts | 12 +- src/components/chat/types/types.ts | 14 ++ .../chat/utils/messageTransforms.ts | 28 ++- src/components/chat/view/ChatInterface.tsx | 2 + .../chat/view/subcomponents/ChatComposer.tsx | 9 +- .../view/subcomponents/MessageComponent.tsx | 2 + .../view/subcomponents/MainContentTitle.tsx | 4 +- 16 files changed, 450 insertions(+), 31 deletions(-) create mode 100644 src/components/chat/tools/components/SubagentContainer.tsx diff --git a/server/claude-sdk.js b/server/claude-sdk.js index ea47c37..150fa9e 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -250,7 +250,13 @@ function getAllSessions() { * @returns {Object} Transformed message ready for WebSocket */ function transformMessage(sdkMessage) { - // Pass-through; SDK messages match frontend format. + // Extract parent_tool_use_id for subagent tool grouping + if (sdkMessage.parent_tool_use_id) { + return { + ...sdkMessage, + parentToolUseId: sdkMessage.parent_tool_use_id + }; + } return sdkMessage; } diff --git a/server/projects.js b/server/projects.js index 50a22c5..b736bbb 100755 --- a/server/projects.js +++ b/server/projects.js @@ -889,22 +889,81 @@ async function parseJsonlSessions(filePath) { } } +// Parse an agent JSONL file and extract tool uses +async function parseAgentTools(filePath) { + const tools = []; + + try { + const fileStream = fsSync.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + + for await (const line of rl) { + if (line.trim()) { + try { + const entry = JSON.parse(line); + // Look for assistant messages with tool_use + if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) { + for (const part of entry.message.content) { + if (part.type === 'tool_use') { + tools.push({ + toolId: part.id, + toolName: part.name, + toolInput: part.input, + timestamp: entry.timestamp + }); + } + } + } + // Look for tool results + if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) { + for (const part of entry.message.content) { + if (part.type === 'tool_result') { + // Find the matching tool and add result + const tool = tools.find(t => t.toolId === part.tool_use_id); + if (tool) { + tool.toolResult = { + content: typeof part.content === 'string' ? part.content : + Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') : + JSON.stringify(part.content), + isError: Boolean(part.is_error) + }; + } + } + } + } + } catch (parseError) { + // Skip malformed lines + } + } + } + } catch (error) { + console.warn(`Error parsing agent file ${filePath}:`, error.message); + } + + return tools; +} + // Get messages for a specific session with pagination support async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); try { const files = await fs.readdir(projectDir); - // agent-*.jsonl files contain session start data at this point. This needs to be revisited - // periodically to make sure only accurate data is there and no new functionality is added there + // agent-*.jsonl files contain subagent tool history - we'll process them separately const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); - + const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-')); + if (jsonlFiles.length === 0) { return { messages: [], total: 0, hasMore: false }; } - + const messages = []; - + // Map of agentId -> tools for subagent tool grouping + const agentToolsCache = new Map(); + // Process all JSONL files to find messages for this session for (const file of jsonlFiles) { const jsonlFile = path.join(projectDir, file); @@ -913,7 +972,7 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = input: fileStream, crlfDelay: Infinity }); - + for await (const line of rl) { if (line.trim()) { try { @@ -927,26 +986,55 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = } } } - + + // Collect agentIds from Task tool results + const agentIds = new Set(); + for (const message of messages) { + if (message.toolUseResult?.agentId) { + agentIds.add(message.toolUseResult.agentId); + } + } + + // Load agent tools for each agentId found + for (const agentId of agentIds) { + const agentFileName = `agent-${agentId}.jsonl`; + if (agentFiles.includes(agentFileName)) { + const agentFilePath = path.join(projectDir, agentFileName); + const tools = await parseAgentTools(agentFilePath); + agentToolsCache.set(agentId, tools); + } + } + + // Attach agent tools to their parent Task messages + for (const message of messages) { + if (message.toolUseResult?.agentId) { + const agentId = message.toolUseResult.agentId; + const agentTools = agentToolsCache.get(agentId); + if (agentTools && agentTools.length > 0) { + message.subagentTools = agentTools; + } + } + } + // Sort messages by timestamp - const sortedMessages = messages.sort((a, b) => + const sortedMessages = messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0) ); - + const total = sortedMessages.length; - + // If no limit is specified, return all messages (backward compatibility) if (limit === null) { return sortedMessages; } - + // Apply pagination - for recent messages, we need to slice from the end // offset 0 should give us the most recent messages const startIndex = Math.max(0, total - offset - limit); const endIndex = total - offset; const paginatedMessages = sortedMessages.slice(startIndex, endIndex); const hasMore = startIndex > 0; - + return { messages: paginatedMessages, total, diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 100129d..ab436aa 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -106,7 +106,7 @@ export default function AppContent() { )} -
+
{ + setIsInputFocused(focused); onInputFocusChange?.(focused); }, [onInputFocusChange], @@ -953,5 +956,6 @@ export function useChatComposerState({ handlePermissionDecision, handleGrantToolPermission, handleInputFocusChange, + isInputFocused, }; } diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 9d2071b..e84f726 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -336,9 +336,43 @@ export function useChatRealtimeHandlers({ } if (structuredMessageData && Array.isArray(structuredMessageData.content)) { + const parentToolUseId = rawStructuredData?.parentToolUseId; + structuredMessageData.content.forEach((part: any) => { if (part.type === 'tool_use') { const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ''; + + // Check if this is a child tool from a subagent + if (parentToolUseId) { + setChatMessages((previous) => + previous.map((message) => { + if (message.toolId === parentToolUseId && message.isSubagentContainer) { + const childTool = { + toolId: part.id, + toolName: part.name, + toolInput: part.input, + toolResult: null, + timestamp: new Date(), + }; + const existingChildren = message.subagentState?.childTools || []; + return { + ...message, + subagentState: { + childTools: [...existingChildren, childTool], + currentToolIndex: existingChildren.length, + isComplete: false, + }, + }; + } + return message; + }), + ); + return; + } + + // Check if this is a Task tool (subagent container) + const isSubagentContainer = part.name === 'Task'; + setChatMessages((previous) => [ ...previous, { @@ -350,6 +384,10 @@ export function useChatRealtimeHandlers({ toolInput, toolId: part.id, toolResult: null, + isSubagentContainer, + subagentState: isSubagentContainer + ? { childTools: [], currentToolIndex: -1, isComplete: false } + : undefined, }, ]); return; @@ -382,6 +420,8 @@ export function useChatRealtimeHandlers({ } if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) { + const parentToolUseId = rawStructuredData?.parentToolUseId; + structuredMessageData.content.forEach((part: any) => { if (part.type !== 'tool_result') { return; @@ -389,8 +429,32 @@ export function useChatRealtimeHandlers({ setChatMessages((previous) => previous.map((message) => { - if (message.isToolUse && message.toolId === part.tool_use_id) { + // Handle child tool results (route to parent's subagentState) + if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) { return { + ...message, + subagentState: { + ...message.subagentState!, + childTools: message.subagentState!.childTools.map((child) => { + if (child.toolId === part.tool_use_id) { + return { + ...child, + toolResult: { + content: part.content, + isError: part.is_error, + timestamp: new Date(), + }, + }; + } + return child; + }), + }, + }; + } + + // Handle normal tool results (including parent Task tool completion) + if (message.isToolUse && message.toolId === part.tool_use_id) { + const result = { ...message, toolResult: { content: part.content, @@ -398,6 +462,14 @@ export function useChatRealtimeHandlers({ timestamp: new Date(), }, }; + // Mark subagent as complete when parent Task receives its result + if (message.isSubagentContainer && message.subagentState) { + result.subagentState = { + ...message.subagentState, + isComplete: true, + }; + } + return result; } return message; }), diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 0d4fa07..e3c88b0 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -1,7 +1,8 @@ import React, { memo, useMemo, useCallback } from 'react'; import { getToolConfig } from './configs/toolConfigs'; -import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components'; +import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components'; import type { Project } from '../../../types/app'; +import type { SubagentChildTool } from '../types/types'; type DiffLine = { type: string; @@ -21,6 +22,12 @@ interface ToolRendererProps { autoExpandTools?: boolean; showRawParameters?: boolean; rawToolInput?: string; + isSubagentContainer?: boolean; + subagentState?: { + childTools: SubagentChildTool[]; + currentToolIndex: number; + isComplete: boolean; + }; } function getToolCategory(toolName: string): string { @@ -50,8 +57,24 @@ export const ToolRenderer: React.FC = memo(({ selectedProject, autoExpandTools = false, showRawParameters = false, - rawToolInput + rawToolInput, + isSubagentContainer, + subagentState }) => { + // Route subagent containers to dedicated component + if (isSubagentContainer && subagentState) { + if (mode === 'result') { + return null; + } + return ( + + ); + } + const config = getToolConfig(toolName); const displayConfig: any = mode === 'input' ? config.input : config.result; diff --git a/src/components/chat/tools/components/CollapsibleSection.tsx b/src/components/chat/tools/components/CollapsibleSection.tsx index f83135c..0d6615d 100644 --- a/src/components/chat/tools/components/CollapsibleSection.tsx +++ b/src/components/chat/tools/components/CollapsibleSection.tsx @@ -24,7 +24,7 @@ export const CollapsibleSection: React.FC = ({ }) => { return (
- + { + const input = typeof toolInput === 'string' ? (() => { + try { return JSON.parse(toolInput); } catch { return {}; } + })() : (toolInput || {}); + + switch (toolName) { + case 'Read': + case 'Write': + case 'Edit': + case 'ApplyPatch': + return input.file_path?.split('/').pop() || input.file_path || ''; + case 'Grep': + case 'Glob': + return input.pattern || ''; + case 'Bash': + const cmd = input.command || ''; + return cmd.length > 40 ? `${cmd.slice(0, 40)}...` : cmd; + case 'Task': + return input.description || input.subagent_type || ''; + case 'WebFetch': + case 'WebSearch': + return input.url || input.query || ''; + default: + return ''; + } +}; + +export const SubagentContainer: React.FC = ({ + toolInput, + toolResult, + subagentState, +}) => { + const parsedInput = typeof toolInput === 'string' ? (() => { + try { return JSON.parse(toolInput); } catch { return {}; } + })() : (toolInput || {}); + + const subagentType = parsedInput?.subagent_type || 'Agent'; + const description = parsedInput?.description || 'Running task'; + const prompt = parsedInput?.prompt || ''; + const { childTools, currentToolIndex, isComplete } = subagentState; + const currentTool = currentToolIndex >= 0 ? childTools[currentToolIndex] : null; + + const title = `Subagent / ${subagentType}: ${description}`; + + return ( +
+ + {/* Prompt/request to the subagent */} + {prompt && ( +
+ {prompt} +
+ )} + + {/* Current tool indicator (while running) */} + {currentTool && !isComplete && ( +
+ + Currently: + {currentTool.toolName} + {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && ( + <> + / + + {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)} + + + )} +
+ )} + + {/* Completion status */} + {isComplete && ( +
+ + + + Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'}) +
+ )} + + {/* Tool history (collapsed) */} + {childTools.length > 0 && ( +
+ + + + + View tool history ({childTools.length}) + +
+ {childTools.map((child, index) => ( +
+ {index + 1}. + {child.toolName} + {getCompactToolDisplay(child.toolName, child.toolInput) && ( + + {getCompactToolDisplay(child.toolName, child.toolInput)} + + )} + {child.toolResult?.isError && ( + (error) + )} +
+ ))} +
+
+ )} + + {/* Final result */} + {isComplete && toolResult && ( +
+ {(() => { + let content = toolResult.content; + + // Handle JSON string that needs parsing + if (typeof content === 'string') { + try { + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + // Extract text from array format like [{"type":"text","text":"..."}] + const textParts = parsed + .filter((p: any) => p.type === 'text' && p.text) + .map((p: any) => p.text); + if (textParts.length > 0) { + content = textParts.join('\n'); + } + } + } catch { + // Not JSON, use as-is + } + } else if (Array.isArray(content)) { + // Direct array format + const textParts = content + .filter((p: any) => p.type === 'text' && p.text) + .map((p: any) => p.text); + if (textParts.length > 0) { + content = textParts.join('\n'); + } + } + + return typeof content === 'string' ? ( +
+ {content} +
+ ) : content ? ( +
+                  {JSON.stringify(content, null, 2)}
+                
+ ) : null; + })()} +
+ )} +
+
+ ); +}; diff --git a/src/components/chat/tools/components/index.ts b/src/components/chat/tools/components/index.ts index 71cdb52..88dc4e5 100644 --- a/src/components/chat/tools/components/index.ts +++ b/src/components/chat/tools/components/index.ts @@ -2,5 +2,6 @@ export { CollapsibleSection } from './CollapsibleSection'; export { DiffViewer } from './DiffViewer'; export { OneLineDisplay } from './OneLineDisplay'; export { CollapsibleDisplay } from './CollapsibleDisplay'; +export { SubagentContainer } from './SubagentContainer'; export * from './ContentRenderers'; export * from './InteractiveRenderers'; diff --git a/src/components/chat/tools/configs/toolConfigs.ts b/src/components/chat/tools/configs/toolConfigs.ts index 994f0df..556334a 100644 --- a/src/components/chat/tools/configs/toolConfigs.ts +++ b/src/components/chat/tools/configs/toolConfigs.ts @@ -383,7 +383,7 @@ export const TOOL_CONFIGS: Record = { const description = input.description || 'Running task'; return `Subagent / ${subagentType}: ${description}`; }, - defaultOpen: true, + defaultOpen: false, contentType: 'markdown', getContentProps: (input) => { // If only prompt exists (and required fields), show just the prompt @@ -424,14 +424,8 @@ export const TOOL_CONFIGS: Record = { }, result: { type: 'collapsible', - title: (result) => { - // Check if result has content with type array (agent results often have this structure) - if (result && result.content && Array.isArray(result.content)) { - return 'Subagent Response'; - } - return 'Subagent Result'; - }, - defaultOpen: true, + title: 'Subagent result', + defaultOpen: false, contentType: 'markdown', getContentProps: (result) => { // Handle agent results which may have complex structure diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 825b8a5..66d5074 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -17,6 +17,14 @@ export interface ToolResult { [key: string]: unknown; } +export interface SubagentChildTool { + toolId: string; + toolName: string; + toolInput: unknown; + toolResult?: ToolResult | null; + timestamp: Date; +} + export interface ChatMessage { type: string; content?: string; @@ -32,6 +40,12 @@ export interface ChatMessage { toolResult?: ToolResult | null; toolId?: string; toolCallId?: string; + isSubagentContainer?: boolean; + subagentState?: { + childTools: SubagentChildTool[]; + currentToolIndex: number; + isComplete: boolean; + }; [key: string]: unknown; } diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts index acc65f7..b4a5250 100644 --- a/src/components/chat/utils/messageTransforms.ts +++ b/src/components/chat/utils/messageTransforms.ts @@ -354,7 +354,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { const converted: ChatMessage[] = []; const toolResults = new Map< string, - { content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown } + { content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] } >(); rawMessages.forEach((message) => { @@ -368,6 +368,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { isError: Boolean(part.is_error), timestamp: new Date(message.timestamp || Date.now()), toolUseResult: message.toolUseResult || null, + subagentTools: message.subagentTools, }); }); } @@ -484,6 +485,22 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { if (part.type === 'tool_use') { const toolResult = toolResults.get(part.id); + const isSubagentContainer = part.name === 'Task'; + + // Build child tools from server-provided subagentTools data + const childTools: import('../types/types').SubagentChildTool[] = []; + if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) { + for (const tool of toolResult.subagentTools as any[]) { + childTools.push({ + toolId: tool.toolId, + toolName: tool.toolName, + toolInput: tool.toolInput, + toolResult: tool.toolResult || null, + timestamp: new Date(tool.timestamp || Date.now()), + }); + } + } + converted.push({ type: 'assistant', content: '', @@ -491,6 +508,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { isToolUse: true, toolName: part.name, toolInput: normalizeToolInput(part.input), + toolId: part.id, toolResult: toolResult ? { content: @@ -503,6 +521,14 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { : null, toolError: toolResult?.isError || false, toolResultTimestamp: toolResult?.timestamp || new Date(), + isSubagentContainer, + subagentState: isSubagentContainer + ? { + childTools, + currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1, + isComplete: Boolean(toolResult), + } + : undefined, }); } }); diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 80b25cd..946a080 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -163,6 +163,7 @@ function ChatInterface({ handlePermissionDecision, handleGrantToolPermission, handleInputFocusChange, + isInputFocused, } = useChatComposerState({ selectedProject, selectedSession, @@ -373,6 +374,7 @@ function ChatInterface({ onTextareaScrollSync={syncInputOverlayScroll} onTextareaInput={handleTextareaInput} onInputFocusChange={handleInputFocusChange} + isInputFocused={isInputFocused} placeholder={t('input.placeholder', { provider: provider === 'cursor' diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 8a9bb44..68afec9 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -87,6 +87,7 @@ interface ChatComposerProps { onTextareaScrollSync: (target: HTMLTextAreaElement) => void; onTextareaInput: (event: FormEvent) => void; onInputFocusChange?: (focused: boolean) => void; + isInputFocused?: boolean; placeholder: string; isTextareaExpanded: boolean; sendByCtrlEnter?: boolean; @@ -143,6 +144,7 @@ export default function ChatComposer({ onTextareaScrollSync, onTextareaInput, onInputFocusChange, + isInputFocused, placeholder, isTextareaExpanded, sendByCtrlEnter, @@ -162,8 +164,13 @@ export default function ChatComposer({ (r) => r.toolName === 'AskUserQuestion' ); + // On mobile, when input is focused, float the input box at the bottom + const mobileFloatingClass = isInputFocused + ? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]' + : ''; + return ( -
+
{!hasQuestionPanel && (
)} diff --git a/src/components/main-content/view/subcomponents/MainContentTitle.tsx b/src/components/main-content/view/subcomponents/MainContentTitle.tsx index 75cf91c..3d11d1e 100644 --- a/src/components/main-content/view/subcomponents/MainContentTitle.tsx +++ b/src/components/main-content/view/subcomponents/MainContentTitle.tsx @@ -62,8 +62,8 @@ export default function MainContentTitle({
) : showChatNewSession ? (
-

{t('mainContent.newSession')}

-
{selectedProject.displayName}
+

{t('mainContent.newSession')}

+
{selectedProject.displayName}
) : (