454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
ChevronRight,
|
|
ChevronUp,
|
|
Loader2,
|
|
ShieldAlert,
|
|
} from "lucide-react";
|
|
import type { Message } from "@langchain/langgraph-sdk";
|
|
import type {
|
|
ActionRequest,
|
|
ReviewConfig,
|
|
SubAgent,
|
|
ToolCall,
|
|
} from "@/app/types/types";
|
|
import type { SubAgentStep } from "@/lib/subAgentActivity";
|
|
import {
|
|
mapActionRequestsToToolCalls,
|
|
summarizeActionGroup,
|
|
} from "@/lib/actionGrouping";
|
|
import { mergeApprovalDecision } from "@/lib/approvalDecision";
|
|
import { formatToolLabel, toolArgumentPreview } from "@/lib/toolLabel";
|
|
import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode";
|
|
import {
|
|
extractStringFromMessageContent,
|
|
extractSubAgentContent,
|
|
} from "@/app/utils/utils";
|
|
import { CompactionSummary } from "./CompactionSummary";
|
|
import { MarkdownContent } from "./MarkdownContent";
|
|
import { SubAgentIndicator } from "./SubAgentIndicator";
|
|
import { SubAgentSteps } from "./SubAgentSteps";
|
|
import { ToolCallBox } from "./ToolCallBox";
|
|
import { StreamingMarkdown } from "./StreamingMarkdown";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface GroupedActionItem {
|
|
message: Message;
|
|
toolCalls: ToolCall[];
|
|
showAvatar?: boolean;
|
|
}
|
|
|
|
interface ActionGroupProps {
|
|
items: GroupedActionItem[];
|
|
isStreaming: boolean;
|
|
defaultCollapsed: boolean;
|
|
isAtBottom: boolean;
|
|
lastMessageId: string | undefined;
|
|
isLoading: boolean;
|
|
actionRequests: ActionRequest[];
|
|
submittedActionRequestKeys: Set<string>;
|
|
onActionRequestSubmitted: (key: string) => void;
|
|
reviewConfigsMap: Map<string, ReviewConfig> | null;
|
|
stream?: unknown;
|
|
onResumeInterrupt: (value: unknown, nextReviewMode?: ReviewMode) => void;
|
|
graphId?: string;
|
|
autoApprove: boolean;
|
|
subAgentSteps: Record<string, SubAgentStep[]>;
|
|
ui: any[] | undefined;
|
|
compactionAnchorId: string | null;
|
|
summarizationEvent: { content: string; cutoffIndex: number } | null;
|
|
}
|
|
|
|
const compactMarkdownClass =
|
|
"text-xs leading-5 text-muted-foreground [&_blockquote]:my-2 [&_blockquote]:pl-2 [&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-sm [&_h2]:mb-2 [&_h2]:mt-3 [&_h2]:text-sm [&_h3]:mb-1.5 [&_h3]:mt-3 [&_h3]:text-xs [&_ol]:my-2 [&_ol]:pl-5 [&_p]:mb-2 [&_table]:text-[11px] [&_td]:p-1.5 [&_th]:p-1.5 [&_ul]:my-2 [&_ul]:pl-5";
|
|
|
|
function toSubAgent(toolCall: ToolCall): SubAgent | null {
|
|
if (toolCall.name !== "task") return null;
|
|
const subAgentName = toolCall.args.subagent_type;
|
|
if (typeof subAgentName !== "string" || !subAgentName.trim()) return null;
|
|
|
|
const status: SubAgent["status"] =
|
|
toolCall.status === "error"
|
|
? "error"
|
|
: toolCall.status === "completed"
|
|
? "completed"
|
|
: "pending";
|
|
return {
|
|
id: toolCall.id,
|
|
name: toolCall.name,
|
|
subAgentName,
|
|
input: toolCall.args,
|
|
output: toolCall.result ? { result: toolCall.result } : undefined,
|
|
status,
|
|
};
|
|
}
|
|
|
|
export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
|
|
items,
|
|
isStreaming,
|
|
defaultCollapsed,
|
|
isAtBottom,
|
|
lastMessageId,
|
|
isLoading,
|
|
actionRequests,
|
|
submittedActionRequestKeys,
|
|
onActionRequestSubmitted,
|
|
reviewConfigsMap,
|
|
stream,
|
|
onResumeInterrupt,
|
|
graphId,
|
|
autoApprove,
|
|
subAgentSteps,
|
|
ui,
|
|
compactionAnchorId,
|
|
summarizationEvent,
|
|
}) {
|
|
const [open, setOpen] = useState(() => !defaultCollapsed);
|
|
const [expandedSubAgents, setExpandedSubAgents] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const wasStreamingRef = useRef(isStreaming);
|
|
|
|
const summary = useMemo(() => summarizeActionGroup(items), [items]);
|
|
const currentActionToolCalls = useMemo(
|
|
() =>
|
|
items.find((item) => item.message.id === lastMessageId)?.toolCalls ?? [],
|
|
[items, lastMessageId]
|
|
);
|
|
const groupContainsLastMessage = currentActionToolCalls.length > 0;
|
|
const actionRequestByToolCallId = useMemo(
|
|
() => mapActionRequestsToToolCalls(currentActionToolCalls, actionRequests),
|
|
[actionRequests, currentActionToolCalls]
|
|
);
|
|
const pendingApprovalCalls = useMemo(
|
|
() =>
|
|
currentActionToolCalls.filter(
|
|
(toolCall) =>
|
|
actionRequestByToolCallId.has(toolCall.id) &&
|
|
!submittedActionRequestKeys.has(toolCall.id)
|
|
),
|
|
[
|
|
actionRequestByToolCallId,
|
|
currentActionToolCalls,
|
|
submittedActionRequestKeys,
|
|
]
|
|
);
|
|
const hasPendingApproval =
|
|
!autoApprove && groupContainsLastMessage && pendingApprovalCalls.length > 0;
|
|
|
|
useEffect(() => {
|
|
const wasStreaming = wasStreamingRef.current;
|
|
wasStreamingRef.current = isStreaming;
|
|
if (
|
|
wasStreaming &&
|
|
!isStreaming &&
|
|
!hasPendingApproval &&
|
|
defaultCollapsed &&
|
|
isAtBottom
|
|
) {
|
|
setOpen(false);
|
|
}
|
|
}, [defaultCollapsed, hasPendingApproval, isAtBottom, isStreaming]);
|
|
|
|
const actionRequestsKey = useMemo(
|
|
() =>
|
|
JSON.stringify(
|
|
actionRequests.map((request) => ({
|
|
name: request.name,
|
|
args: request.args,
|
|
}))
|
|
),
|
|
[actionRequests]
|
|
);
|
|
const pendingReviewDecisionsRef = useRef<Record<number, unknown>>({});
|
|
useEffect(() => {
|
|
pendingReviewDecisionsRef.current = {};
|
|
}, [actionRequestsKey]);
|
|
|
|
const handleResumeActionRequest = useCallback(
|
|
(actionIndex: number, value: any, nextReviewMode?: ReviewMode) => {
|
|
const decisions = value?.decisions;
|
|
if (!Array.isArray(decisions) || actionRequests.length === 0) {
|
|
onResumeInterrupt(value, nextReviewMode);
|
|
return;
|
|
}
|
|
if (
|
|
actionRequests.length === 1 ||
|
|
decisions.length === actionRequests.length
|
|
) {
|
|
onResumeInterrupt(value, nextReviewMode);
|
|
return;
|
|
}
|
|
|
|
const merged = mergeApprovalDecision(
|
|
actionRequests.length,
|
|
actionIndex,
|
|
decisions[0],
|
|
pendingReviewDecisionsRef.current,
|
|
nextReviewMode ? autoApprovesTools(nextReviewMode) : false
|
|
);
|
|
pendingReviewDecisionsRef.current = merged.pending;
|
|
if (!merged.decisions) return;
|
|
onResumeInterrupt({ decisions: merged.decisions }, nextReviewMode);
|
|
},
|
|
[actionRequests, onResumeInterrupt]
|
|
);
|
|
|
|
const toggleSubAgent = useCallback((id: string) => {
|
|
setExpandedSubAgents((current) => ({
|
|
...current,
|
|
[id]: !(current[id] ?? false),
|
|
}));
|
|
}, []);
|
|
|
|
const renderToolCall = useCallback(
|
|
(toolCall: ToolCall) => {
|
|
const actionRequestEntry = actionRequestByToolCallId.get(toolCall.id);
|
|
const subAgent = toSubAgent(toolCall);
|
|
|
|
if (subAgent && !actionRequestEntry) {
|
|
const expanded = expandedSubAgents[subAgent.id] ?? false;
|
|
const steps = subAgentSteps[subAgent.id] ?? [];
|
|
return (
|
|
<div
|
|
key={toolCall.id}
|
|
className="py-0.5"
|
|
>
|
|
<SubAgentIndicator
|
|
subAgent={subAgent}
|
|
onClick={() => toggleSubAgent(subAgent.id)}
|
|
isExpanded={expanded}
|
|
/>
|
|
{expanded && (
|
|
<div className="ml-2 mt-1 space-y-3 border-l border-border pl-3">
|
|
<div>
|
|
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
|
|
Input
|
|
</div>
|
|
<MarkdownContent
|
|
content={extractSubAgentContent(subAgent.input)}
|
|
className={compactMarkdownClass}
|
|
/>
|
|
</div>
|
|
{steps.length > 0 && (
|
|
<div>
|
|
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
|
|
Steps
|
|
</div>
|
|
<SubAgentSteps
|
|
steps={steps}
|
|
hideFinalText={!!subAgent.output}
|
|
compact
|
|
/>
|
|
</div>
|
|
)}
|
|
{subAgent.output && (
|
|
<div>
|
|
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
|
|
Output
|
|
</div>
|
|
<MarkdownContent
|
|
content={extractSubAgentContent(subAgent.output)}
|
|
className={compactMarkdownClass}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const toolUiComponent = ui?.find(
|
|
(entry) => entry.metadata?.tool_call_id === toolCall.id
|
|
);
|
|
return (
|
|
<ToolCallBox
|
|
key={toolCall.id}
|
|
toolCall={toolCall}
|
|
uiComponent={toolUiComponent}
|
|
stream={stream}
|
|
graphId={graphId}
|
|
actionRequest={actionRequestEntry?.actionRequest}
|
|
actionRequestKey={actionRequestEntry ? toolCall.id : undefined}
|
|
actionRequestSubmitted={
|
|
actionRequestEntry
|
|
? submittedActionRequestKeys.has(toolCall.id)
|
|
: undefined
|
|
}
|
|
onActionRequestSubmitted={onActionRequestSubmitted}
|
|
reviewConfig={reviewConfigsMap?.get(toolCall.name)}
|
|
onResume={
|
|
actionRequestEntry
|
|
? (value, nextReviewMode) =>
|
|
handleResumeActionRequest(
|
|
actionRequestEntry.actionIndex,
|
|
value,
|
|
nextReviewMode
|
|
)
|
|
: onResumeInterrupt
|
|
}
|
|
isLoading={isLoading}
|
|
autoApprove={autoApprove}
|
|
compact
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
actionRequestByToolCallId,
|
|
autoApprove,
|
|
expandedSubAgents,
|
|
graphId,
|
|
handleResumeActionRequest,
|
|
isLoading,
|
|
onActionRequestSubmitted,
|
|
onResumeInterrupt,
|
|
reviewConfigsMap,
|
|
stream,
|
|
subAgentSteps,
|
|
submittedActionRequestKeys,
|
|
toggleSubAgent,
|
|
ui,
|
|
]
|
|
);
|
|
|
|
const lastToolCall = summary.lastToolCall;
|
|
const lastToolLabel = lastToolCall
|
|
? formatToolLabel(lastToolCall.name, lastToolCall.args)
|
|
: "Working";
|
|
const lastToolPreview = lastToolCall
|
|
? toolArgumentPreview(lastToolCall.name, lastToolCall.args)
|
|
: "";
|
|
const actionNoun = summary.total === 1 ? "action" : "actions";
|
|
const primaryText = hasPendingApproval
|
|
? "Approval required"
|
|
: summary.failed > 0
|
|
? `${summary.failed} of ${summary.total} ${actionNoun} failed`
|
|
: isStreaming || summary.pending > 0 || summary.interrupted > 0
|
|
? `Working · ${summary.total} ${actionNoun}`
|
|
: `${summary.total} ${actionNoun} completed`;
|
|
const headerText = `${primaryText} · ${lastToolLabel}${
|
|
lastToolPreview ? ` · ${lastToolPreview}` : ""
|
|
}`;
|
|
|
|
return (
|
|
<section
|
|
className="my-2 overflow-hidden rounded-md border border-border bg-[var(--color-surface)]"
|
|
aria-label="Assistant work process"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
aria-label={`${open ? "Collapse" : "Expand"} ${headerText}`}
|
|
title={headerText}
|
|
onClick={() => setOpen((current) => !current)}
|
|
className="flex min-h-10 w-full items-center gap-2 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
>
|
|
<ChevronRight
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"size-3.5 shrink-0 transition-transform",
|
|
open && "rotate-90"
|
|
)}
|
|
/>
|
|
{hasPendingApproval ? (
|
|
<ShieldAlert
|
|
aria-hidden="true"
|
|
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
|
|
/>
|
|
) : summary.failed > 0 ? (
|
|
<AlertCircle
|
|
aria-hidden="true"
|
|
className="size-3.5 shrink-0 text-destructive"
|
|
/>
|
|
) : isStreaming || summary.pending > 0 || summary.interrupted > 0 ? (
|
|
<Loader2
|
|
aria-hidden="true"
|
|
className="size-3.5 shrink-0 animate-spin text-[var(--brand)]"
|
|
/>
|
|
) : (
|
|
<CheckCircle2
|
|
aria-hidden="true"
|
|
className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
|
|
/>
|
|
)}
|
|
<span className="shrink-0 font-medium text-foreground">
|
|
{primaryText}
|
|
</span>
|
|
<span aria-hidden="true">·</span>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{lastToolLabel}
|
|
{lastToolPreview ? ` · ${lastToolPreview}` : ""}
|
|
</span>
|
|
</button>
|
|
|
|
{!open && hasPendingApproval && (
|
|
<div className="space-y-1 border-t border-border px-3 py-2">
|
|
{pendingApprovalCalls.map(renderToolCall)}
|
|
</div>
|
|
)}
|
|
|
|
{open && (
|
|
<div className="border-t border-border px-3 py-2">
|
|
<div className="space-y-1 border-l border-border pl-3">
|
|
{items.map((item, itemIndex) => {
|
|
const content = extractStringFromMessageContent(item.message);
|
|
const showCompactionBefore =
|
|
compactionAnchorId === item.message.id;
|
|
const itemIsStreaming =
|
|
isStreaming && item.message.id === lastMessageId;
|
|
|
|
return (
|
|
<React.Fragment
|
|
key={item.message.id ?? `action-item-${itemIndex}`}
|
|
>
|
|
{showCompactionBefore && summarizationEvent && (
|
|
<CompactionSummary
|
|
content={summarizationEvent.content}
|
|
summarizedCount={summarizationEvent.cutoffIndex}
|
|
/>
|
|
)}
|
|
{content.trim() && (
|
|
<div className="px-2 py-1.5">
|
|
<StreamingMarkdown
|
|
content={content}
|
|
isStreaming={itemIsStreaming}
|
|
className={compactMarkdownClass}
|
|
/>
|
|
</div>
|
|
)}
|
|
{item.toolCalls.map(renderToolCall)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="mt-1 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-sm text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={`Collapse ${summary.total} ${actionNoun}`}
|
|
>
|
|
<ChevronUp
|
|
aria-hidden="true"
|
|
className="size-3.5"
|
|
/>
|
|
Collapse
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
});
|
|
|
|
ActionGroup.displayName = "ActionGroup";
|