188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, memo } from "react";
|
|
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
|
|
import { GridSpinner } from "@/components/grid-spinner";
|
|
import { cn } from "@/lib/utils";
|
|
import type { Message } from "@/lib/types/http";
|
|
import type { ToolCallMetadata } from "@/components/task/chat/types";
|
|
import { SubagentMetaRow } from "@/components/task/chat/messages/subagent-meta-row";
|
|
|
|
type ToolSubagentMessageProps = {
|
|
comment: Message;
|
|
childMessages: Message[];
|
|
worktreePath?: string;
|
|
onOpenFile?: (path: string) => void;
|
|
renderChild: (message: Message) => React.ReactNode;
|
|
};
|
|
|
|
// Custom comparison function that ignores renderChild (it's always recreated but referentially stable in behavior)
|
|
function arePropsEqual(
|
|
prevProps: ToolSubagentMessageProps,
|
|
nextProps: ToolSubagentMessageProps,
|
|
): boolean {
|
|
return (
|
|
prevProps.comment === nextProps.comment &&
|
|
prevProps.childMessages === nextProps.childMessages &&
|
|
prevProps.worktreePath === nextProps.worktreePath &&
|
|
prevProps.onOpenFile === nextProps.onOpenFile
|
|
);
|
|
}
|
|
|
|
type SubagentHeaderProps = {
|
|
isExpanded: boolean;
|
|
subagentType: string;
|
|
description: string;
|
|
isActive: boolean;
|
|
childCount: number;
|
|
onToggle: () => void;
|
|
};
|
|
|
|
function SubagentHeader({
|
|
isExpanded,
|
|
subagentType,
|
|
description,
|
|
isActive,
|
|
childCount,
|
|
onToggle,
|
|
}: SubagentHeaderProps) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full text-left px-2 py-1.5 -mx-2 rounded",
|
|
"hover:bg-muted/30 transition-colors cursor-pointer",
|
|
)}
|
|
>
|
|
{isExpanded ? (
|
|
<IconChevronDown className="h-3.5 w-3.5 text-muted-foreground/60 flex-shrink-0" />
|
|
) : (
|
|
<IconChevronRight className="h-3.5 w-3.5 text-muted-foreground/60 flex-shrink-0" />
|
|
)}
|
|
<span
|
|
data-testid="subagent-type"
|
|
className="bg-muted text-muted-foreground text-[10px] px-1.5 rounded font-medium uppercase tracking-wide"
|
|
>
|
|
{subagentType}
|
|
</span>
|
|
<span
|
|
data-testid="subagent-description"
|
|
className="font-mono text-xs truncate text-muted-foreground inline-flex items-center gap-1.5"
|
|
>
|
|
{description}
|
|
{isActive && <GridSpinner className="text-muted-foreground shrink-0" />}
|
|
</span>
|
|
{childCount > 0 && (
|
|
<span
|
|
data-testid="subagent-child-count"
|
|
className="text-muted-foreground/60 text-xs px-1.5 rounded min-w-[20px] text-center font-mono"
|
|
>
|
|
{childCount} tool call{childCount !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const NESTED_BORDER = "ml-2 pl-4 border-l-2 border-border/30 mt-1";
|
|
|
|
type SubagentContentProps = {
|
|
isExpanded: boolean;
|
|
childMessages: Message[];
|
|
isActive: boolean;
|
|
prompt?: string;
|
|
renderChild: (message: Message) => React.ReactNode;
|
|
};
|
|
|
|
function SubagentContent({
|
|
isExpanded,
|
|
childMessages,
|
|
isActive,
|
|
prompt,
|
|
renderChild,
|
|
}: SubagentContentProps) {
|
|
if (!isExpanded) return null;
|
|
if (childMessages.length > 0) {
|
|
return (
|
|
<div className={cn(NESTED_BORDER, "space-y-2")}>
|
|
{childMessages.map((child) => (
|
|
<div key={child.id}>{renderChild(child)}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
if (isActive) {
|
|
return (
|
|
<div className={NESTED_BORDER}>
|
|
<span className="text-xs text-muted-foreground italic">Subagent working...</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (prompt) {
|
|
return (
|
|
<div className={NESTED_BORDER}>
|
|
<p className="text-xs text-muted-foreground whitespace-pre-wrap break-words">{prompt}</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const ToolSubagentMessage = memo(function ToolSubagentMessage({
|
|
comment,
|
|
childMessages,
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
worktreePath,
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
onOpenFile,
|
|
renderChild,
|
|
}: ToolSubagentMessageProps) {
|
|
const metadata = comment.metadata as ToolCallMetadata | undefined;
|
|
const status = metadata?.status;
|
|
const normalized = metadata?.normalized;
|
|
const subagentTask = normalized?.subagent_task;
|
|
|
|
const description = subagentTask?.description || comment.content || "Subagent";
|
|
const subagentType = subagentTask?.subagent_type || "Task";
|
|
const childCount = childMessages.length;
|
|
|
|
const isActive = status === "running";
|
|
const showMeta = !isActive;
|
|
const prompt = childCount === 0 ? subagentTask?.prompt : undefined;
|
|
|
|
// Track manual override state - null means "use auto behavior"
|
|
const [manualExpandState, setManualExpandState] = useState<boolean | null>(null);
|
|
|
|
// Auto behavior: expand if active, collapse otherwise
|
|
const autoExpanded = isActive;
|
|
|
|
// Derive expanded state: manual override takes precedence, otherwise use auto
|
|
const isExpanded = manualExpandState ?? autoExpanded;
|
|
|
|
const handleToggle = useCallback(() => {
|
|
setManualExpandState((prev) => !(prev ?? autoExpanded));
|
|
}, [autoExpanded]);
|
|
|
|
return (
|
|
<div className="w-full" data-testid="subagent-card">
|
|
<SubagentHeader
|
|
isExpanded={isExpanded}
|
|
subagentType={subagentType}
|
|
description={description}
|
|
isActive={isActive}
|
|
childCount={childCount}
|
|
onToggle={handleToggle}
|
|
/>
|
|
{showMeta && <SubagentMetaRow subagentTask={subagentTask} />}
|
|
<SubagentContent
|
|
isExpanded={isExpanded}
|
|
childMessages={childMessages}
|
|
isActive={isActive}
|
|
prompt={prompt}
|
|
renderChild={renderChild}
|
|
/>
|
|
</div>
|
|
);
|
|
}, arePropsEqual);
|