mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +02:00
chat: drop legacy thinking-steps, tool-fallback, hitl modules, and span-indent helper.
This commit is contained in:
parent
9c5a178468
commit
ba0e1e70a0
14 changed files with 0 additions and 1942 deletions
|
|
@ -1,433 +0,0 @@
|
|||
import {
|
||||
makeAssistantDataUI,
|
||||
type ToolCallMessagePartComponent,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TOOLS_BY_NAME, TOOLS_FALLBACK } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { HitlRenderTargetProvider, isInterruptResult } from "@/lib/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ThinkingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
items: string[];
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
/**
|
||||
* Optional relay metadata forwarded from ``data-thinking-step`` SSE
|
||||
* (e.g. ``spanId`` set by ``AgentEventRelayState.span_metadata_if_active``).
|
||||
* Steps under an open delegating ``task`` carry ``metadata.spanId`` and are
|
||||
* grouped under the preceding parent (``task`` step) as indented children.
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-step info joined from the assistant message ``tool-call`` parts via
|
||||
* the shared ``metadata.thinkingStepId`` correlation
|
||||
* (set on the server in ``AgentEventRelayState.tool_activity_metadata``).
|
||||
*
|
||||
* Carries enough of the part to:
|
||||
* - identify the opening ``task`` step and substitute the subagent display
|
||||
* name on the parent header (uses ``toolName`` and ``args``);
|
||||
* - render the matching tool component inline under the step row when the
|
||||
* card's result is an HITL interrupt (uses ``toolCallId``, ``argsText``,
|
||||
* ``result``, ``langchainToolCallId``).
|
||||
*/
|
||||
interface StepToolInfo {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
argsText?: string;
|
||||
result?: unknown;
|
||||
langchainToolCallId?: string;
|
||||
}
|
||||
|
||||
export type ThinkingStepToolInfoMap = ReadonlyMap<string, StepToolInfo>;
|
||||
|
||||
/**
|
||||
* Build ``thinkingStepId → StepToolInfo`` from message content. Used to
|
||||
* - identify the opening ``task`` step (parent header, never indents) without
|
||||
* relying on the human-readable title;
|
||||
* - render the parent's display title from ``args.subagent_type`` instead of
|
||||
* the generic "Task" copy;
|
||||
* - mount the matching tool-call card inline under a step row when the
|
||||
* result is an HITL interrupt (see ``TimelineHitlCard``).
|
||||
*/
|
||||
export function buildThinkingStepToolInfo(
|
||||
content: readonly unknown[] | undefined
|
||||
): ThinkingStepToolInfoMap {
|
||||
const m = new Map<string, StepToolInfo>();
|
||||
if (!content) return m;
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const o = part as {
|
||||
type?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
args?: Record<string, unknown>;
|
||||
argsText?: string;
|
||||
result?: unknown;
|
||||
langchainToolCallId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
if (o.type !== "tool-call" || !o.toolName || !o.toolCallId) continue;
|
||||
const tid = o.metadata?.thinkingStepId;
|
||||
if (typeof tid === "string" && tid.trim().length > 0) {
|
||||
m.set(tid, {
|
||||
toolCallId: o.toolCallId,
|
||||
toolName: o.toolName,
|
||||
args: o.args ?? {},
|
||||
argsText: o.argsText,
|
||||
result: o.result,
|
||||
langchainToolCallId: o.langchainToolCallId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function asNonEmptyString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
function titleCaseSubagent(raw: string): string {
|
||||
// "notion" → "Notion", "doc_research" → "Doc Research".
|
||||
return raw
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title for a step. For the opening ``task`` step we substitute the
|
||||
* subagent type from the matching tool-call args (e.g. ``"Notion"`` instead of
|
||||
* the generic ``"Task"``). Falls back to the step's own title if the tool-call
|
||||
* hasn't streamed in yet.
|
||||
*/
|
||||
function resolveDisplayTitle(step: ThinkingStep, info: StepToolInfo | undefined): string {
|
||||
if (info?.toolName === "task") {
|
||||
const subagent = asNonEmptyString(info.args?.subagent_type);
|
||||
if (subagent) return titleCaseSubagent(subagent);
|
||||
}
|
||||
return step.title;
|
||||
}
|
||||
|
||||
function isDelegatedChild(step: ThinkingStep, info: StepToolInfo | undefined): boolean {
|
||||
const sid = asNonEmptyString(step.metadata?.spanId);
|
||||
if (!sid) return false;
|
||||
// The opening ``task`` step also carries ``spanId`` (it owns the span) but
|
||||
// must render as the parent header. Prefer the joined ``toolName`` (set by
|
||||
// ``buildThinkingStepToolInfo`` from ``tool-call.metadata.thinkingStepId``).
|
||||
// Fall back to the title heuristic when no tool-call is matched — happens
|
||||
// for messages persisted before ``thinkingStepId`` shipped, and briefly
|
||||
// during streaming if the ``tool-input-start`` frame hasn't been processed
|
||||
// yet for some reason.
|
||||
if (info) return info.toolName !== "task";
|
||||
return step.title !== "Task";
|
||||
}
|
||||
|
||||
interface StepGroup {
|
||||
parent: ThinkingStep;
|
||||
children: ThinkingStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive delegated child steps under the preceding parent step.
|
||||
* If the very first step is a child (no parent yet seen), it's promoted to a
|
||||
* parent so it still renders — defensive only, real flows always start with a
|
||||
* parent step.
|
||||
*/
|
||||
const EMPTY_STEP_TOOL_INFO: ThinkingStepToolInfoMap = new Map();
|
||||
|
||||
function groupSteps(
|
||||
steps: readonly ThinkingStep[],
|
||||
stepToolInfo: ThinkingStepToolInfoMap
|
||||
): StepGroup[] {
|
||||
const groups: StepGroup[] = [];
|
||||
for (const step of steps) {
|
||||
if (isDelegatedChild(step, stepToolInfo.get(step.id)) && groups.length > 0) {
|
||||
groups[groups.length - 1].children.push(step);
|
||||
} else {
|
||||
groups.push({ parent: step, children: [] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const StepBody: FC<{
|
||||
step: ThinkingStep;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
displayTitle: string;
|
||||
}> = ({ step, status, displayTitle }) => (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-5",
|
||||
status === "in_progress" && "text-foreground font-medium",
|
||||
status === "completed" && "text-muted-foreground",
|
||||
status === "pending" && "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{displayTitle}
|
||||
</div>
|
||||
|
||||
{step.items && step.items.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{step.items.map((item) => (
|
||||
<ChainOfThoughtItem key={`${step.id}-${item}`} className="text-xs">
|
||||
{item}
|
||||
</ChainOfThoughtItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Mount the same tool-call UI used in the message body, but inside the
|
||||
* chain-of-thought timeline. The body copy returns ``null`` (see
|
||||
* ``withHitlInTimeline`` in ``lib/hitl/render-target``), so the card
|
||||
* effectively moves from the body to the timeline for the lifetime of the
|
||||
* interrupt (pending → processing → complete / rejected).
|
||||
*
|
||||
* ``metadata`` is intentionally omitted from the props we forward — the
|
||||
* step row already provides any indentation it needs, so we don't want
|
||||
* ``withDelegationSpanIndent`` to add a second indent + border on top.
|
||||
*
|
||||
* ``status`` is a placeholder (HITL UIs read only ``args`` + ``result``)
|
||||
* so we don't need to mirror assistant-ui's runtime status object here.
|
||||
*/
|
||||
const TimelineHitlCard: FC<{ info: StepToolInfo }> = ({ info }) => {
|
||||
const Comp =
|
||||
(TOOLS_BY_NAME as Record<string, ToolCallMessagePartComponent | undefined>)[info.toolName] ??
|
||||
TOOLS_FALLBACK;
|
||||
const props = {
|
||||
toolCallId: info.toolCallId,
|
||||
toolName: info.toolName,
|
||||
args: info.args,
|
||||
argsText: info.argsText,
|
||||
result: info.result,
|
||||
langchainToolCallId: info.langchainToolCallId,
|
||||
status: { type: "complete" } as const,
|
||||
};
|
||||
return (
|
||||
<HitlRenderTargetProvider value="timeline">
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: ToolCallMessagePartProps requires
|
||||
runtime-only fields (addResult, resume, MessagePartState) we don't have when
|
||||
re-rendering manually; HITL components only read args + result. */}
|
||||
<Comp {...(props as any)} />
|
||||
</HitlRenderTargetProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function hitlInterruptInfo(info: StepToolInfo | undefined): StepToolInfo | undefined {
|
||||
return info && isInterruptResult(info.result) ? info : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain of thought display component - single collapsible dropdown design.
|
||||
*
|
||||
* ``stepToolInfo`` joins each step (by ``thinkingStepId``) to its ``tool-call``
|
||||
* part so we can:
|
||||
* - replace the generic ``"Task"`` title with the real subagent name
|
||||
* (``args.subagent_type``) on the parent header;
|
||||
* - decide parent-vs-child purely from the matched ``toolName`` instead of
|
||||
* relying on the displayed title.
|
||||
*/
|
||||
export const ThinkingStepsDisplay: FC<{
|
||||
steps: ThinkingStep[];
|
||||
isThreadRunning?: boolean;
|
||||
stepToolInfo?: ThinkingStepToolInfoMap;
|
||||
}> = ({ steps, isThreadRunning = true, stepToolInfo }) => {
|
||||
const toolInfo = stepToolInfo ?? EMPTY_STEP_TOOL_INFO;
|
||||
const getEffectiveStatus = useCallback(
|
||||
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
|
||||
if (step.status === "in_progress" && !isThreadRunning) {
|
||||
return "completed";
|
||||
}
|
||||
return step.status;
|
||||
},
|
||||
[isThreadRunning]
|
||||
);
|
||||
|
||||
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
|
||||
const inProgressDisplayTitle = inProgressStep
|
||||
? resolveDisplayTitle(inProgressStep, toolInfo.get(inProgressStep.id))
|
||||
: undefined;
|
||||
const allCompleted =
|
||||
steps.length > 0 &&
|
||||
!isThreadRunning &&
|
||||
steps.every((s) => getEffectiveStatus(s) === "completed");
|
||||
const isProcessing = isThreadRunning && !allCompleted;
|
||||
const [isOpen, setIsOpen] = useState(() => isProcessing);
|
||||
|
||||
useEffect(() => {
|
||||
if (isProcessing) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allCompleted) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [allCompleted, isProcessing]);
|
||||
|
||||
const groups = useMemo(() => groupSteps(steps, toolInfo), [steps, toolInfo]);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
const getHeaderText = () => {
|
||||
if (allCompleted) {
|
||||
return "Reviewed";
|
||||
}
|
||||
if (inProgressDisplayTitle) {
|
||||
return inProgressDisplayTitle;
|
||||
}
|
||||
if (isProcessing) {
|
||||
return "Processing";
|
||||
}
|
||||
return "Reviewed";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||
<div className="rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<TextShimmerLoader text={getHeaderText()} size="sm" />
|
||||
) : (
|
||||
<span>{getHeaderText()}</span>
|
||||
)}
|
||||
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-3 pl-1">
|
||||
{groups.map((group, groupIndex) => {
|
||||
const isLastGroup = groupIndex === groups.length - 1;
|
||||
const parentStatus = getEffectiveStatus(group.parent);
|
||||
const parentInfo = toolInfo.get(group.parent.id);
|
||||
const parentTitle = resolveDisplayTitle(group.parent, parentInfo);
|
||||
const hasChildren = group.children.length > 0;
|
||||
// Parent dots are connected by a vertical line that runs through
|
||||
// any indented children (their column has no dot, so the line
|
||||
// passes cleanly behind them) and overshoots by ~15px to reach
|
||||
// the next group's dot center (top-[15px]).
|
||||
const showParentLine = !isLastGroup;
|
||||
|
||||
return (
|
||||
<div key={group.parent.id} className="relative flex gap-3">
|
||||
<div className="relative flex flex-col items-center w-2 self-stretch">
|
||||
{showParentLine && (
|
||||
<div className="absolute left-1/2 top-[15px] -bottom-[15px] w-px -translate-x-1/2 bg-muted-foreground/30" />
|
||||
)}
|
||||
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
|
||||
{parentStatus === "in_progress" ? (
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pb-4">
|
||||
<StepBody
|
||||
step={group.parent}
|
||||
status={parentStatus}
|
||||
displayTitle={parentTitle}
|
||||
/>
|
||||
|
||||
{(() => {
|
||||
const hitl = hitlInterruptInfo(parentInfo);
|
||||
return hitl ? <TimelineHitlCard info={hitl} /> : null;
|
||||
})()}
|
||||
|
||||
{hasChildren && (
|
||||
<div className="mt-2 ml-3 space-y-2">
|
||||
{group.children.map((child) => {
|
||||
const childInfo = toolInfo.get(child.id);
|
||||
const childHitl = hitlInterruptInfo(childInfo);
|
||||
return (
|
||||
<div key={child.id}>
|
||||
<StepBody
|
||||
step={child}
|
||||
status={getEffectiveStatus(child)}
|
||||
displayTitle={resolveDisplayTitle(child, childInfo)}
|
||||
/>
|
||||
{childHitl && <TimelineHitlCard info={childHitl} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* assistant-ui data UI component that renders thinking steps from message content.
|
||||
* Registered globally via makeAssistantDataUI — renders inside MessagePrimitive.Parts
|
||||
* at the position of the data part in the content array.
|
||||
*/
|
||||
function ThinkingStepsDataRenderer({ data }: { name: string; data: unknown }) {
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false);
|
||||
const isMessageStreaming = isThreadRunning && isLastMessage;
|
||||
const content = useAuiState(({ message }) => message?.content);
|
||||
|
||||
const stepToolInfo = useMemo(
|
||||
() => buildThinkingStepToolInfo(Array.isArray(content) ? content : undefined),
|
||||
[content]
|
||||
);
|
||||
|
||||
const steps = (data as { steps: ThinkingStep[] } | null)?.steps ?? [];
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3 -mx-2 leading-normal">
|
||||
<ThinkingStepsDisplay
|
||||
steps={steps}
|
||||
isThreadRunning={isMessageStreaming}
|
||||
stepToolInfo={stepToolInfo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ThinkingStepsDataUI = makeAssistantDataUI({
|
||||
name: "thinking-steps",
|
||||
render: ThinkingStepsDataRenderer,
|
||||
});
|
||||
|
|
@ -1,532 +0,0 @@
|
|||
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CheckIcon, ChevronDownIcon, RotateCcw, XCircleIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import { NestedScroll } from "@/components/assistant-ui/nested-scroll";
|
||||
import {
|
||||
DoomLoopApprovalToolUI,
|
||||
isDoomLoopInterrupt,
|
||||
} from "@/components/tool-ui/doom-loop-approval";
|
||||
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||
import { markActionRevertedInCache, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import {
|
||||
DELEGATION_SPAN_INDENT_CLASS,
|
||||
shouldIndentToolCallForDelegationSpan,
|
||||
} from "@/lib/chat/delegation-span-indent";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Inline Revert button rendered on a tool card when the matching
|
||||
* ``AgentActionLog`` row is reversible and hasn't been reverted yet.
|
||||
*
|
||||
* Reads from the unified ``useAgentActionsQuery`` cache — the SAME
|
||||
* react-query cache the agent-actions sheet consumes. SSE events
|
||||
* (``data-action-log`` / ``data-action-log-updated``) and
|
||||
* ``POST /threads/{id}/revert/{id}`` responses both flow through the
|
||||
* cache via ``setQueryData`` helpers, so the card and the sheet stay
|
||||
* in lockstep on every code path: page reload, navigation, live
|
||||
* stream, post-stream reversibility flip, and explicit revert clicks.
|
||||
*
|
||||
* Match key (in priority order):
|
||||
* 1. ``a.tool_call_id === toolCallId`` — direct hit when the model
|
||||
* streamed ``tool_call_chunks`` so the card id matches the LangChain id.
|
||||
* 2. ``a.tool_call_id === langchainToolCallId`` — synthetic card id is
|
||||
* ``call_<run_id>`` and the LangChain id is backfilled by
|
||||
* ``tool-output-available``.
|
||||
* 3. ``(chat_turn_id, tool_name, position-within-turn)`` — fallback
|
||||
* for cards whose synthetic id is ``call_<run_id>`` AND whose
|
||||
* ``langchainToolCallId`` never got backfilled (provider emitted
|
||||
* the tool_call as a single payload with no chunks AND streaming
|
||||
* pre-dated the ``tool-output-available langchainToolCallId``
|
||||
* backfill, e.g. older threads). Reads the parent message's
|
||||
* ``chatTurnId`` and ``content`` via ``useAuiState`` so we can
|
||||
* match position-by-tool-name within the turn against the
|
||||
* action_log rows the server returned in ``created_at`` order.
|
||||
*/
|
||||
function ToolCardRevertButton({
|
||||
toolCallId,
|
||||
toolName,
|
||||
langchainToolCallId,
|
||||
}: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
langchainToolCallId?: string;
|
||||
}) {
|
||||
const session = useAtomValue(chatSessionStateAtom);
|
||||
const threadId = session?.threadId ?? null;
|
||||
const queryClient = useQueryClient();
|
||||
const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId);
|
||||
|
||||
// Parent message metadata, read via the narrowest possible
|
||||
// selectors so this card doesn't re-render on every text-delta of
|
||||
// every other part in the same message during streaming.
|
||||
//
|
||||
// IMPORTANT — ``useAuiState`` re-renders the component whenever the
|
||||
// returned slice's identity changes. Returning ``message?.content``
|
||||
// (an array) would re-render on every token because the runtime
|
||||
// rebuilds the parts array. Returning a PRIMITIVE (the position
|
||||
// number) lets ``useAuiState``'s ``Object.is`` check short-circuit
|
||||
// when the position hasn't actually moved — which is the common
|
||||
// case during text streaming, when only ``text``/``reasoning``
|
||||
// parts are mutating and the same-toolName tool-call ordering is
|
||||
// stable. (See Vercel React rule ``rerender-defer-reads``.)
|
||||
const chatTurnId = useAuiState(({ message }) => {
|
||||
const meta = message?.metadata as { custom?: { chatTurnId?: string } } | undefined;
|
||||
return meta?.custom?.chatTurnId ?? null;
|
||||
});
|
||||
const positionInTurn = useAuiState(({ message }) => {
|
||||
const content = message?.content;
|
||||
if (!Array.isArray(content)) return -1;
|
||||
let n = -1;
|
||||
for (const part of content) {
|
||||
if (
|
||||
part &&
|
||||
typeof part === "object" &&
|
||||
(part as { type?: string }).type === "tool-call" &&
|
||||
(part as { toolName?: string }).toolName === toolName
|
||||
) {
|
||||
n += 1;
|
||||
if ((part as { toolCallId?: string }).toolCallId === toolCallId) return n;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
|
||||
const action = useMemo(() => {
|
||||
// Tier 1 + 2: O(1) Map-backed direct id match. Covers
|
||||
// Indexed chunk streams and any stream that backfilled
|
||||
// ``langchainToolCallId`` via ``tool-output-available``.
|
||||
const direct = findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId);
|
||||
if (direct) return direct;
|
||||
// Tier 3: position-within-turn fallback. Only kicks in when the
|
||||
// card has a synthetic ``call_<run_id>`` id AND no
|
||||
// ``langchainToolCallId`` was ever backfilled — i.e. the tool
|
||||
// was emitted as a single non-chunked payload AND streaming
|
||||
// pre-dated the on_tool_end backfill.
|
||||
if (!chatTurnId || positionInTurn < 0) return null;
|
||||
const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName);
|
||||
return turnSameTool[positionInTurn] ?? null;
|
||||
}, [
|
||||
findByToolCallId,
|
||||
findByChatTurnAndTool,
|
||||
toolCallId,
|
||||
langchainToolCallId,
|
||||
chatTurnId,
|
||||
toolName,
|
||||
positionInTurn,
|
||||
]);
|
||||
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
if (!action) return null;
|
||||
if (!action.reversible) return null;
|
||||
if (action.reverted_by_action_id !== null && action.reverted_by_action_id !== undefined)
|
||||
return null;
|
||||
if (action.is_revert_action) return null;
|
||||
if (action.error !== null && action.error !== undefined) return null;
|
||||
if (!threadId) return null;
|
||||
|
||||
const handleRevert = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revert(threadId, action.id);
|
||||
markActionRevertedInCache(queryClient, threadId, action.id, response.new_action_id ?? null);
|
||||
toast.success(response.message || "Action reverted.");
|
||||
} catch (err) {
|
||||
// 503 means revert is gated off on this deployment — hide the
|
||||
// button silently rather than nagging the user. Any other error
|
||||
// is surfaced as a toast so the operator can investigate.
|
||||
if (err instanceof AppError && err.status === 503) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof AppError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to revert action.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsReverting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? (
|
||||
// Spinner's typed props don't accept ``data-icon`` and
|
||||
// it renders an <output>, not an <svg>, so Button's
|
||||
// auto-sizing rule doesn't apply. Bare spinner +
|
||||
// Button's gap handle layout.
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<RotateCcw data-icon="inline-start" />
|
||||
)}
|
||||
Revert
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo{" "}
|
||||
<span className="font-medium">{getToolDisplayName(action.tool_name)}</span> and add a
|
||||
new entry to the history. Your chat is preserved — only the changes the agent made to
|
||||
your knowledge base or connected apps will be rolled back where possible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRevert();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isReverting && <Spinner size="xs" />}
|
||||
Revert
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact tool-call card.
|
||||
*
|
||||
* shadcn composition note: we intentionally use ``Card`` as a visual
|
||||
* frame WITHOUT ``CardHeader / CardContent``. The full composition's
|
||||
* ``p-6`` padding doesn't fit a compact collapsible header that IS the
|
||||
* trigger; using ``Card`` alone preserves the rounded border, shadow,
|
||||
* and ``bg-card`` token (semantic colors) without forcing a layout
|
||||
* that doesn't fit. All status colors use semantic tokens — no manual
|
||||
* dark-mode overrides, no raw hex.
|
||||
*/
|
||||
const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
|
||||
const { toolCallId, toolName, argsText, result, status } = props;
|
||||
// ``langchainToolCallId`` is a SurfSense-specific extension the
|
||||
// streaming pipeline attaches to the tool-call content part so
|
||||
// the Revert button can resolve its ``AgentActionLog`` row even
|
||||
// when only the LC id is known. assistant-ui's
|
||||
// ``ToolCallMessagePartProps`` doesn't list it, but the runtime
|
||||
// spreads ``{...part}`` so the prop reaches us at runtime.
|
||||
const langchainToolCallId = (props as { langchainToolCallId?: string }).langchainToolCallId;
|
||||
|
||||
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
|
||||
const isError = status?.type === "incomplete" && status.reason === "error";
|
||||
const isRunning = status?.type === "running" || status?.type === "requires-action";
|
||||
|
||||
/*
|
||||
Per-card expansion state. Initial value is ``isRunning`` so a
|
||||
card streaming in mounts already-expanded (no flash of
|
||||
collapsed → expanded on first paint), while a card loaded from
|
||||
history (status="complete") mounts collapsed. The useEffect
|
||||
below keeps this in lockstep with this card's own ``isRunning``
|
||||
when it transitions: false → true auto-expands (e.g. a tool
|
||||
that re-runs after edit), true → false auto-collapses once the
|
||||
tool finishes. Because the dep is per-card ``isRunning`` and
|
||||
not the chat-level streaming flag, sibling cards on the same
|
||||
assistant turn each manage their own expansion independently.
|
||||
Once ``isRunning`` is false the user controls expansion via
|
||||
``onOpenChange``.
|
||||
*/
|
||||
const [isExpanded, setIsExpanded] = useState(isRunning);
|
||||
useEffect(() => {
|
||||
setIsExpanded(isRunning);
|
||||
}, [isRunning]);
|
||||
const errorData = status?.type === "incomplete" ? status.error : undefined;
|
||||
const serializedError = useMemo(
|
||||
() => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null),
|
||||
[errorData]
|
||||
);
|
||||
|
||||
const serializedResult = useMemo(
|
||||
() =>
|
||||
result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null,
|
||||
[result]
|
||||
);
|
||||
|
||||
const cancelledReason =
|
||||
isCancelled && status.error
|
||||
? typeof status.error === "string"
|
||||
? status.error
|
||||
: serializedError
|
||||
: null;
|
||||
const errorReason =
|
||||
isError && status.error
|
||||
? typeof status.error === "string"
|
||||
? status.error
|
||||
: serializedError
|
||||
: null;
|
||||
|
||||
const displayName = getToolDisplayName(toolName);
|
||||
const subtitle = errorReason ?? cancelledReason;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"my-4 max-w-lg overflow-hidden",
|
||||
isCancelled && "opacity-60",
|
||||
isError && "border-destructive/30"
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
``group`` lets the chevron (rendered as a sibling of the
|
||||
main trigger button) read the Collapsible Root's
|
||||
``data-[state=open]`` for rotation. The Collapsible is
|
||||
fully controlled via ``isExpanded`` — the useEffect
|
||||
above syncs it to ``isRunning`` so the card auto-opens
|
||||
while a tool streams in and auto-collapses once it
|
||||
finishes. We deliberately DON'T pass ``disabled`` so
|
||||
both triggers stay clickable; ``onOpenChange`` is wired
|
||||
to a setter that no-ops while ``isRunning`` (see
|
||||
``handleOpenChange`` below) which keeps the card pinned
|
||||
open mid-stream without losing keyboard / pointer
|
||||
affordance the moment streaming ends.
|
||||
*/}
|
||||
<Collapsible
|
||||
className="group"
|
||||
open={isExpanded}
|
||||
onOpenChange={(next) => {
|
||||
// Block manual collapse while the tool is still
|
||||
// streaming — otherwise a stray click on either
|
||||
// trigger would close the card and hide the live
|
||||
// ``argsText`` panel mid-run. After streaming the
|
||||
// user has full control again.
|
||||
if (isRunning) return;
|
||||
setIsExpanded(next);
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
Header row: main trigger on the left (icon + title
|
||||
col), Revert + chevron-trigger on the right as
|
||||
siblings of the main trigger. The chevron is wrapped
|
||||
in its OWN ``CollapsibleTrigger`` (Radix supports
|
||||
multiple triggers per Root) so clicking the chevron
|
||||
toggles the same state as clicking the title row.
|
||||
The Revert button stays a separate AlertDialog
|
||||
trigger and stops propagation in its onClick so it
|
||||
doesn't toggle the collapsible while opening the
|
||||
confirm dialog. Keeping these as flat siblings —
|
||||
rather than nesting Revert / chevron inside the
|
||||
title trigger — avoids invalid HTML
|
||||
(button-in-button) and lets the Revert button
|
||||
render in BOTH the collapsed and expanded states.
|
||||
*/}
|
||||
<div className="flex items-stretch transition-colors hover:bg-muted/50">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 min-w-0 items-center gap-3 py-4 pl-5 pr-2 text-left",
|
||||
// Inset ring — Card's ``overflow-hidden`` would
|
||||
// clip an ``offset-2`` ring; ``ring-inset``
|
||||
// paints inside the button box.
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
"disabled:cursor-default"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg",
|
||||
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{isError ? (
|
||||
<XCircleIcon className="size-4 text-destructive" />
|
||||
) : isCancelled ? (
|
||||
<XCircleIcon className="size-4 text-muted-foreground" />
|
||||
) : isRunning ? (
|
||||
<Spinner size="sm" className="text-primary" />
|
||||
) : (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold truncate",
|
||||
isCancelled && "text-muted-foreground line-through",
|
||||
isError && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{displayName}
|
||||
</p>
|
||||
{isRunning && <Badge variant="secondary">Running</Badge>}
|
||||
{isError && <Badge variant="destructive">Failed</Badge>}
|
||||
{isCancelled && <Badge variant="outline">Cancelled</Badge>}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs truncate",
|
||||
isError ? "text-destructive/80" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/*
|
||||
Right-side controls. The Revert button is
|
||||
visible whenever the matching action is
|
||||
reversible — including the collapsed state —
|
||||
but ``ToolCardRevertButton`` itself returns
|
||||
``null`` while a tool is still running because
|
||||
no action-log row exists yet, so it doesn't
|
||||
need an explicit ``isRunning`` gate here.
|
||||
*/}
|
||||
<div className="flex shrink-0 items-center gap-2 pl-2 pr-5">
|
||||
<ToolCardRevertButton
|
||||
toolCallId={toolCallId}
|
||||
toolName={toolName}
|
||||
langchainToolCallId={langchainToolCallId}
|
||||
/>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? "Collapse details" : "Expand details"}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
"disabled:cursor-default"
|
||||
)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
"group-data-[state=open]:rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
CollapsibleContent body — auto-open while streaming
|
||||
(see ``open`` prop above) so the live ``argsText``
|
||||
streams into the Inputs panel directly, no need for
|
||||
a separate "Live input" panel. Native
|
||||
``overflow-auto`` instead of ``ScrollArea`` because
|
||||
Radix's Viewport can let content bleed past
|
||||
``max-h-*`` in dynamic flex layouts. ``min-w-0`` on
|
||||
the column wrappers guarantees ``break-all`` wraps
|
||||
correctly within the bounded ``max-w-lg`` Card.
|
||||
*/}
|
||||
<CollapsibleContent>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-3 px-5 py-3">
|
||||
{(argsText || isRunning) && (
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
|
||||
<NestedScroll className="max-h-48 overflow-auto rounded-md bg-muted/40">
|
||||
{argsText ? (
|
||||
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
|
||||
{argsText}
|
||||
</pre>
|
||||
) : (
|
||||
// Bridges the brief gap between
|
||||
// ``tool-input-start`` (creates the
|
||||
// card, ``argsText`` undefined) and
|
||||
// the first ``tool-input-delta``.
|
||||
<p className="px-3 py-2 text-xs italic text-muted-foreground">
|
||||
Waiting for input…
|
||||
</p>
|
||||
)}
|
||||
</NestedScroll>
|
||||
</div>
|
||||
)}
|
||||
{!isCancelled && result !== undefined && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Result</p>
|
||||
<NestedScroll className="max-h-64 overflow-auto rounded-md bg-muted/40">
|
||||
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
|
||||
{typeof result === "string" ? result : serializedResult}
|
||||
</pre>
|
||||
</NestedScroll>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap any tool-call UI so cards under an active delegating ``task`` span indent.
|
||||
* Applied to named tool components as well as ``ToolFallback`` — only ``ToolFallback``
|
||||
* would miss delegated tools otherwise.
|
||||
*/
|
||||
export function withDelegationSpanIndent(
|
||||
Component: ToolCallMessagePartComponent
|
||||
): ToolCallMessagePartComponent {
|
||||
const Wrapped: ToolCallMessagePartComponent = (props) => {
|
||||
const metadata = (props as { metadata?: Record<string, unknown> }).metadata;
|
||||
const indent = shouldIndentToolCallForDelegationSpan(props.toolName, metadata);
|
||||
const inner = <Component {...props} />;
|
||||
return indent ? <div className={cn(DELEGATION_SPAN_INDENT_CLASS)}>{inner}</div> : inner;
|
||||
};
|
||||
Wrapped.displayName = `withDelegationSpanIndent(${Component.displayName ?? Component.name ?? "ToolUI"})`;
|
||||
return Wrapped;
|
||||
}
|
||||
|
||||
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
|
||||
if (isInterruptResult(props.result)) {
|
||||
if (isDoomLoopInterrupt(props.result)) {
|
||||
return <DoomLoopApprovalToolUI {...props} />;
|
||||
}
|
||||
return <GenericHitlApprovalToolUI {...props} />;
|
||||
}
|
||||
return <DefaultToolFallbackInner {...props} />;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue