335 lines
11 KiB
TypeScript
335 lines
11 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";
|
|
|
|
type ToolSubagentMessageProps = {
|
|
comment: Message;
|
|
childMessages: Message[];
|
|
isContainingTurnActive?: boolean;
|
|
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.isContainingTurnActive === nextProps.isContainingTurnActive &&
|
|
prevProps.worktreePath === nextProps.worktreePath &&
|
|
prevProps.onOpenFile === nextProps.onOpenFile
|
|
);
|
|
}
|
|
|
|
const TERMINAL_TOOL_STATUSES = new Set([
|
|
"complete",
|
|
"completed",
|
|
"success",
|
|
"error",
|
|
"failed",
|
|
"cancelled",
|
|
]);
|
|
|
|
export function isSubagentEffectivelyActive(
|
|
metadata: ToolCallMetadata | undefined,
|
|
isContainingTurnActive: boolean,
|
|
): boolean {
|
|
const status = metadata?.status;
|
|
if (status && TERMINAL_TOOL_STATUSES.has(status)) return false;
|
|
if (status === "running") return true;
|
|
const payloadStatus = metadata?.normalized?.subagent_task?.status;
|
|
return isContainingTurnActive && (status === "in_progress" || payloadStatus === "started");
|
|
}
|
|
|
|
// The result is what the subagent was dispatched to produce, so it is shown
|
|
// whether or not the subagent also streamed child tool calls. The prompt is a
|
|
// fallback for subagents that reported neither.
|
|
function deriveSubagentBody(
|
|
childCount: number,
|
|
subagentTask: SubagentTaskPayload | undefined,
|
|
): { resultText?: string; prompt?: string } {
|
|
if (subagentTask?.result_text) return { resultText: subagentTask.result_text };
|
|
if (childCount > 0) return {};
|
|
if (subagentTask?.prompt) return { prompt: subagentTask.prompt };
|
|
return {};
|
|
}
|
|
|
|
/** The collapsed card carries one line of the result — the first non-empty one,
|
|
* which is where agents put the verdict. Everything else waits for expand. */
|
|
export function firstResultLine(resultText: string | undefined): string {
|
|
if (!resultText) return "";
|
|
for (const line of resultText.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (trimmed) return trimmed;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function deriveSubagentDisplay(
|
|
metadata: ToolCallMetadata | undefined,
|
|
childCount: number,
|
|
isContainingTurnActive: boolean,
|
|
) {
|
|
const subagentTask = metadata?.normalized?.subagent_task;
|
|
const isActive = isSubagentEffectivelyActive(metadata, isContainingTurnActive);
|
|
const { resultText, prompt } = deriveSubagentBody(childCount, subagentTask);
|
|
return {
|
|
subagentTask,
|
|
isActive,
|
|
resultText,
|
|
prompt,
|
|
hasExpandableContent: childCount > 0 || Boolean(resultText) || Boolean(prompt),
|
|
};
|
|
}
|
|
|
|
/** The type chip already names the subagent, so a description that opens by
|
|
* restating it ("test-supervisor" + "Test-supervisor review of new invariant
|
|
* tests") spends the line it truncates in on a word already on screen. The
|
|
* match ignores case and the separators agents vary on, and only strips at a
|
|
* word boundary — "code-reviewer" must not eat the "code-review" in
|
|
* "code-review of the closure diff". Returns "" when the description is
|
|
* nothing but the type. */
|
|
export function stripSubagentTypePrefix(description: string, subagentType: string): string {
|
|
const normalize = (value: string) => value.toLowerCase().replace(/[\s\-_]+/g, "");
|
|
const target = normalize(subagentType);
|
|
if (!target) return description;
|
|
let consumed = "";
|
|
for (let i = 0; i < description.length; i++) {
|
|
consumed += normalize(description[i]);
|
|
if (consumed.length < target.length) continue;
|
|
if (consumed !== target) return description;
|
|
// Only whitespace or a colon separates a type from its description. `.`
|
|
// and `,` are not boundaries: type "test" would otherwise eat the filename
|
|
// in "test.ts regression suite" and leave "ts regression suite".
|
|
const rest = description.slice(i + 1);
|
|
if (rest !== "" && !/^[\s:]/.test(rest)) return description;
|
|
return rest.replace(/^[\s:]+/, "");
|
|
}
|
|
return description;
|
|
}
|
|
|
|
type SubagentHeaderProps = {
|
|
isExpanded: boolean;
|
|
subagentType: string;
|
|
description: string;
|
|
isActive: boolean;
|
|
childCount: number;
|
|
hasExpandableContent: boolean;
|
|
onToggle: () => void;
|
|
};
|
|
|
|
function SubagentHeader({
|
|
isExpanded,
|
|
subagentType,
|
|
description,
|
|
isActive,
|
|
childCount,
|
|
hasExpandableContent,
|
|
onToggle,
|
|
}: SubagentHeaderProps) {
|
|
const shownDescription = stripSubagentTypePrefix(description, subagentType);
|
|
const showDescription = shownDescription !== "";
|
|
const showInlineWorking = isActive && !hasExpandableContent;
|
|
const content = (
|
|
<>
|
|
{hasExpandableContent &&
|
|
(isExpanded ? (
|
|
<IconChevronDown
|
|
data-testid="subagent-chevron"
|
|
className="h-3.5 w-3.5 text-muted-foreground/60 flex-shrink-0"
|
|
/>
|
|
) : (
|
|
<IconChevronRight
|
|
data-testid="subagent-chevron"
|
|
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>
|
|
{showDescription && (
|
|
<span
|
|
data-testid="subagent-description"
|
|
title={description}
|
|
className="font-mono text-xs truncate text-muted-foreground min-w-0"
|
|
>
|
|
{shownDescription}
|
|
</span>
|
|
)}
|
|
{showInlineWorking && (
|
|
<span className="text-xs text-muted-foreground italic">Working...</span>
|
|
)}
|
|
{isActive && <GridSpinner className="text-muted-foreground shrink-0" />}
|
|
{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 whitespace-nowrap"
|
|
>
|
|
{childCount} tool call{childCount !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
if (!hasExpandableContent) {
|
|
return (
|
|
<div
|
|
data-testid="subagent-header"
|
|
className="flex items-center gap-2 w-full text-left px-2 py-1.5 -mx-2 rounded"
|
|
>
|
|
{content}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-expanded={isExpanded}
|
|
onClick={onToggle}
|
|
className={cn(
|
|
"flex min-h-11 items-center gap-2 w-full text-left px-2 py-1.5 -mx-2 rounded sm:min-h-0",
|
|
"hover:bg-muted/30 transition-colors cursor-pointer",
|
|
)}
|
|
>
|
|
{content}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const NESTED_BORDER = "ml-2 pl-4 border-l-2 border-border/30 mt-1";
|
|
|
|
type SubagentContentProps = {
|
|
isExpanded: boolean;
|
|
childMessages: Message[];
|
|
prompt?: string;
|
|
resultText?: string;
|
|
renderChild: (message: Message) => React.ReactNode;
|
|
};
|
|
|
|
function SubagentContent({
|
|
isExpanded,
|
|
childMessages,
|
|
prompt,
|
|
resultText,
|
|
renderChild,
|
|
}: SubagentContentProps) {
|
|
if (!isExpanded) return null;
|
|
if (childMessages.length > 0) {
|
|
return (
|
|
<div className={cn(NESTED_BORDER, "space-y-2")}>
|
|
{resultText && (
|
|
<p
|
|
data-testid="subagent-result-text"
|
|
className="text-xs text-foreground/80 whitespace-pre-wrap break-words"
|
|
>
|
|
{resultText}
|
|
</p>
|
|
)}
|
|
{childMessages.map((child) => (
|
|
<div key={child.id}>{renderChild(child)}</div>
|
|
))}
|
|
</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,
|
|
isContainingTurnActive = false,
|
|
// 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 childCount = childMessages.length;
|
|
const { subagentTask, isActive, resultText, prompt, hasExpandableContent } =
|
|
deriveSubagentDisplay(metadata, childCount, isContainingTurnActive);
|
|
const resultSummary = firstResultLine(resultText);
|
|
const description = subagentTask?.description || comment.content || "Subagent";
|
|
const subagentType = subagentTask?.subagent_type || "Task";
|
|
|
|
// Track manual override state - null means "use auto behavior"
|
|
const [manualExpandState, setManualExpandState] = useState<boolean | null>(null);
|
|
|
|
// Auto behavior: expand only while the subagent is active. On completion the
|
|
// card auto-collapses to its header + metadata row, matching subagents that
|
|
// stream child tool calls; the result text is one click away. Previously
|
|
// auto-expand was also keyed on result_text, which left silent (Auggie-style)
|
|
// subagents — the ones with no child messages — permanently expanded because
|
|
// result_text never clears.
|
|
const autoExpanded = isActive;
|
|
|
|
// Derive expanded state: manual override takes precedence, otherwise use auto
|
|
const isExpanded = hasExpandableContent && (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}
|
|
hasExpandableContent={hasExpandableContent}
|
|
onToggle={handleToggle}
|
|
/>
|
|
{!isActive && !isExpanded && resultSummary && (
|
|
<p
|
|
data-testid="subagent-result-summary"
|
|
title={resultSummary}
|
|
className="ml-2 pl-4 text-xs text-foreground/80 truncate"
|
|
>
|
|
{resultSummary}
|
|
</p>
|
|
)}
|
|
{!isActive && <SubagentMetaRow subagentTask={subagentTask} childCount={childCount} />}
|
|
<SubagentContent
|
|
isExpanded={isExpanded}
|
|
childMessages={childMessages}
|
|
prompt={prompt}
|
|
resultText={resultText}
|
|
renderChild={renderChild}
|
|
/>
|
|
</div>
|
|
);
|
|
}, arePropsEqual);
|