232 lines
7.2 KiB
TypeScript
232 lines
7.2 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 { SubagentTaskPayload, ToolCallMetadata } from "@/components/task/chat/types";
|
|
import { SubagentMetaRow } from "@/components/task/chat/messages/subagent-meta-row";
|
|
|
|
// deriveSubagentBody picks which secondary block (result text or prompt) the
|
|
// card should render below the header. Result text wins because it carries the
|
|
// silent-subagent summary that prompt placeholders cannot substitute for.
|
|
function deriveSubagentBody(
|
|
childCount: number,
|
|
payload: SubagentTaskPayload | undefined,
|
|
): { resultText?: string; prompt?: string } {
|
|
if (childCount > 0) return {};
|
|
if (payload?.result_text) return { resultText: payload.result_text };
|
|
if (payload?.prompt) return { prompt: payload.prompt };
|
|
return {};
|
|
}
|
|
|
|
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 whitespace-nowrap flex-shrink-0"
|
|
>
|
|
{subagentType}
|
|
</span>
|
|
<span
|
|
data-testid="subagent-description"
|
|
title={description}
|
|
className="font-mono text-xs truncate text-muted-foreground inline-flex items-center gap-1.5 min-w-0"
|
|
>
|
|
{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;
|
|
resultText?: string;
|
|
renderChild: (message: Message) => React.ReactNode;
|
|
};
|
|
|
|
function SubagentContent({
|
|
isExpanded,
|
|
childMessages,
|
|
isActive,
|
|
prompt,
|
|
resultText,
|
|
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 (resultText) {
|
|
return (
|
|
<div className={NESTED_BORDER}>
|
|
<p
|
|
data-testid="subagent-result-text"
|
|
className="text-xs text-foreground/80 whitespace-pre-wrap break-words"
|
|
>
|
|
{resultText}
|
|
</p>
|
|
</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 { resultText, prompt } = deriveSubagentBody(childCount, subagentTask);
|
|
|
|
// Track manual override state - null means "use auto behavior"
|
|
const [manualExpandState, setManualExpandState] = useState<boolean | null>(null);
|
|
|
|
// Auto behavior: expand if active or if we have a result text to surface
|
|
// (silent Auggie-style subagents have no child messages, so we want the
|
|
// final summary visible without an extra click).
|
|
const autoExpanded = isActive || Boolean(resultText);
|
|
|
|
// Reset the manual override the first time a result_text arrives so a card
|
|
// the user collapsed while the subagent was running auto-opens to show the
|
|
// summary. "Adjust state during render" pattern (preferred over useEffect):
|
|
// https://react.dev/learn/you-might-not-need-an-effect — only fires on the
|
|
// empty -> non-empty transition; later user collapses persist.
|
|
const [prevResultText, setPrevResultText] = useState(resultText);
|
|
if (resultText !== prevResultText) {
|
|
setPrevResultText(resultText);
|
|
if (resultText && !prevResultText) {
|
|
setManualExpandState(null);
|
|
}
|
|
}
|
|
|
|
// 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}
|
|
resultText={resultText}
|
|
renderChild={renderChild}
|
|
/>
|
|
</div>
|
|
);
|
|
}, arePropsEqual);
|