Feat: subagent tool grouping (#398)

* fix(mobile): prevent bottom padding removal on input focus

* fix: change subagent rendering

* fix: subagent task name
This commit is contained in:
viper151
2026-02-19 17:32:45 +01:00
committed by GitHub
parent 38a593c97f
commit 0207a1f3a3
16 changed files with 450 additions and 31 deletions

View File

@@ -106,7 +106,7 @@ export default function AppContent() {
</div>
)}
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}>
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}

View File

@@ -903,8 +903,11 @@ export function useChatComposerState({
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
);
const [isInputFocused, setIsInputFocused] = useState(false);
const handleInputFocusChange = useCallback(
(focused: boolean) => {
setIsInputFocused(focused);
onInputFocusChange?.(focused);
},
[onInputFocusChange],
@@ -953,5 +956,6 @@ export function useChatComposerState({
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused,
};
}

View File

@@ -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;
}),

View File

@@ -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<ToolRendererProps> = 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 (
<SubagentContainer
toolInput={toolInput}
toolResult={toolResult}
subagentState={subagentState}
/>
);
}
const config = getToolConfig(toolName);
const displayConfig: any = mode === 'input' ? config.input : config.result;

View File

@@ -24,7 +24,7 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
}) => {
return (
<details className={`relative group/details ${className}`} open={open}>
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none">
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:bg-background group-open/details:-mx-1 group-open/details:px-1">
<svg
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0"
fill="none"

View File

@@ -0,0 +1,180 @@
import React from 'react';
import { CollapsibleSection } from './CollapsibleSection';
import type { SubagentChildTool } from '../../types/types';
interface SubagentContainerProps {
toolInput: unknown;
toolResult?: { content?: unknown; isError?: boolean } | null;
subagentState: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
}
const getCompactToolDisplay = (toolName: string, toolInput: unknown): string => {
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<SubagentContainerProps> = ({
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 (
<div className="border-l-2 border-l-purple-500 dark:border-l-purple-400 pl-3 py-0.5 my-1">
<CollapsibleSection
title={title}
toolName="Task"
open={false}
>
{/* Prompt/request to the subagent */}
{prompt && (
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 whitespace-pre-wrap break-words line-clamp-4">
{prompt}
</div>
)}
{/* Current tool indicator (while running) */}
{currentTool && !isComplete && (
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mt-1">
<span className="animate-pulse w-1.5 h-1.5 rounded-full bg-purple-500 dark:bg-purple-400 flex-shrink-0" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<>
<span className="text-gray-300 dark:text-gray-600">/</span>
<span className="font-mono truncate text-gray-500 dark:text-gray-400">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span>
</>
)}
</div>
)}
{/* Completion status */}
{isComplete && (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-1">
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>
</div>
)}
{/* Tool history (collapsed) */}
{childTools.length > 0 && (
<details className="mt-2 group/history">
<summary className="cursor-pointer text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1">
<svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/history:rotate-90 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>View tool history ({childTools.length})</span>
</summary>
<div className="mt-1 pl-3 border-l border-gray-200 dark:border-gray-700 space-y-0.5">
{childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span className="text-gray-400 dark:text-gray-500 w-4 text-right flex-shrink-0">{index + 1}.</span>
<span className="font-medium">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="font-mono truncate text-gray-400 dark:text-gray-500">
{getCompactToolDisplay(child.toolName, child.toolInput)}
</span>
)}
{child.toolResult?.isError && (
<span className="text-red-500 flex-shrink-0">(error)</span>
)}
</div>
))}
</div>
</details>
)}
{/* Final result */}
{isComplete && toolResult && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{(() => {
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' ? (
<div className="whitespace-pre-wrap break-words line-clamp-6">
{content}
</div>
) : content ? (
<pre className="whitespace-pre-wrap break-words line-clamp-6 font-mono text-[11px]">
{JSON.stringify(content, null, 2)}
</pre>
) : null;
})()}
</div>
)}
</CollapsibleSection>
</div>
);
};

View File

@@ -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';

View File

@@ -383,7 +383,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
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<string, ToolDisplayConfig> = {
},
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

View File

@@ -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;
}

View File

@@ -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,
});
}
});

View File

@@ -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'

View File

@@ -87,6 +87,7 @@ interface ChatComposerProps {
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => 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 (
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasQuestionPanel && (
<div className="flex-1">
<ClaudeStatus

View File

@@ -184,6 +184,8 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
subagentState={message.subagentState}
/>
)}

View File

@@ -62,8 +62,8 @@ export default function MainContentTitle({
</div>
) : showChatNewSession ? (
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground leading-tight">{t('mainContent.newSession')}</h2>
<div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
<h2 className="text-base font-semibold text-foreground leading-tight">{t('mainContent.newSession')}</h2>
<div className="text-xs text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div>
) : (
<div className="min-w-0">