mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +02:00
chat-messages: add timeline tool registry with HITL-aware fallback.
This commit is contained in:
parent
48c4df822a
commit
97a7626179
9 changed files with 778 additions and 0 deletions
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { ToolCallItem } from "../types";
|
||||||
|
import type { TimelineToolProps } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lossless mapping ``ToolCallItem → TimelineToolProps``. Pure;
|
||||||
|
* extracts only the fields tool components actually consume.
|
||||||
|
*
|
||||||
|
* ``id``, ``kind``, ``items``, ``spanId``, ``thinkingStepId`` are
|
||||||
|
* intentionally dropped — they're timeline-internal concerns (React
|
||||||
|
* key, dispatch, indentation, back-correlation) that tool components
|
||||||
|
* have no reason to see.
|
||||||
|
*/
|
||||||
|
export function adaptItemToProps(item: ToolCallItem): TimelineToolProps {
|
||||||
|
return {
|
||||||
|
toolCallId: item.toolCallId,
|
||||||
|
toolName: item.toolName,
|
||||||
|
args: item.args,
|
||||||
|
argsText: item.argsText,
|
||||||
|
result: item.result,
|
||||||
|
langchainToolCallId: item.langchainToolCallId,
|
||||||
|
status: item.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckIcon, ChevronDownIcon, XCircleIcon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { NestedScroll } from "@/components/assistant-ui/nested-scroll";
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import type { TimelineToolComponent } from "../types";
|
||||||
|
import { ToolCardRevertButton } from "./revert-button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort error/cancellation reason from a tool result. Used as
|
||||||
|
* the card subtitle when ``status`` is "error" or "cancelled". Returns
|
||||||
|
* ``null`` if no usable text can be extracted.
|
||||||
|
*
|
||||||
|
* Tries: plain string → ``result.error`` → ``result.message`` →
|
||||||
|
* stringified result. Per-tool components own richer error UIs; this
|
||||||
|
* is the generic fallback's coarse summary.
|
||||||
|
*/
|
||||||
|
function deriveResultMessage(result: unknown): string | null {
|
||||||
|
if (result == null) return null;
|
||||||
|
if (typeof result === "string") return result;
|
||||||
|
if (typeof result !== "object") return null;
|
||||||
|
const r = result as { error?: unknown; message?: unknown };
|
||||||
|
if (typeof r.error === "string") return r.error;
|
||||||
|
if (typeof r.message === "string") return r.message;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(result);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact tool-call card. Used by ``FallbackToolBody`` for unregistered
|
||||||
|
* tools whose result is not an HITL interrupt.
|
||||||
|
*
|
||||||
|
* shadcn composition note: ``Card`` is used as a visual frame WITHOUT
|
||||||
|
* ``CardHeader``/``CardContent`` — the full composition's ``p-6``
|
||||||
|
* doesn't fit a compact collapsible header that IS the trigger.
|
||||||
|
*
|
||||||
|
* Per-card expansion auto-syncs to ``isRunning`` (auto-expand on
|
||||||
|
* stream start, auto-collapse on completion); manual toggle takes over
|
||||||
|
* once streaming ends.
|
||||||
|
*/
|
||||||
|
export const DefaultFallbackCard: TimelineToolComponent = ({
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
argsText,
|
||||||
|
result,
|
||||||
|
status,
|
||||||
|
langchainToolCallId,
|
||||||
|
}) => {
|
||||||
|
const isCancelled = status === "cancelled";
|
||||||
|
const isError = status === "error";
|
||||||
|
const isRunning = status === "running";
|
||||||
|
|
||||||
|
const [isExpanded, setIsExpanded] = useState(isRunning);
|
||||||
|
useEffect(() => {
|
||||||
|
setIsExpanded(isRunning);
|
||||||
|
}, [isRunning]);
|
||||||
|
|
||||||
|
const serializedResult = useMemo(
|
||||||
|
() =>
|
||||||
|
result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null,
|
||||||
|
[result]
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitle = useMemo(
|
||||||
|
() => (isError || isCancelled ? deriveResultMessage(result) : null),
|
||||||
|
[isError, isCancelled, result]
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayName = getToolDisplayName(toolName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"my-4 max-w-lg overflow-hidden",
|
||||||
|
isCancelled && "opacity-60",
|
||||||
|
isError && "border-destructive/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Collapsible
|
||||||
|
className="group"
|
||||||
|
open={isExpanded}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (isRunning) return;
|
||||||
|
setIsExpanded(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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",
|
||||||
|
"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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={isExpanded ? "Collapse details" : "Expand details"}
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
"size-4 transition-transform duration-200",
|
||||||
|
"group-data-[state=open]:rotate-180"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DoomLoopApproval,
|
||||||
|
GenericHitlApproval,
|
||||||
|
type InterruptResult,
|
||||||
|
isDoomLoopInterrupt,
|
||||||
|
isInterruptResult,
|
||||||
|
} from "@/features/chat-messages/hitl";
|
||||||
|
import type { TimelineToolComponent } from "../types";
|
||||||
|
import { DefaultFallbackCard } from "./default-fallback-card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounted by the timeline for any tool name not in the registry. The
|
||||||
|
* fallback owns the inner discrimination between HITL approval cards
|
||||||
|
* and the default visual card:
|
||||||
|
*
|
||||||
|
* isInterruptResult(result) ─┬─ isDoomLoopInterrupt → DoomLoopApproval
|
||||||
|
* └─ otherwise → GenericHitlApproval
|
||||||
|
* else → DefaultFallbackCard
|
||||||
|
*
|
||||||
|
* This is the ONLY place ``isInterruptResult`` is checked for unknown
|
||||||
|
* tools. Per-tool components in ``components/tool-ui/*`` perform their
|
||||||
|
* own internal discrimination over richer result shapes; the fallback
|
||||||
|
* only knows the two top-level branches.
|
||||||
|
*/
|
||||||
|
export const FallbackToolBody: TimelineToolComponent = (props) => {
|
||||||
|
if (isInterruptResult(props.result)) {
|
||||||
|
const approvalProps = {
|
||||||
|
toolCallId: props.toolCallId,
|
||||||
|
toolName: props.toolName,
|
||||||
|
args: props.args,
|
||||||
|
result: props.result as InterruptResult,
|
||||||
|
};
|
||||||
|
if (isDoomLoopInterrupt(props.result)) {
|
||||||
|
return <DoomLoopApproval {...approvalProps} />;
|
||||||
|
}
|
||||||
|
return <GenericHitlApproval {...approvalProps} />;
|
||||||
|
}
|
||||||
|
return <DefaultFallbackCard {...props} />;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { FallbackToolBody } from "./fallback-tool-body";
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||||
|
import { markActionRevertedInCache } from "@/hooks/use-agent-actions-query";
|
||||||
|
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||||
|
import { AppError } from "@/lib/error";
|
||||||
|
import { useToolAction } from "./use-tool-action";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline Revert button rendered on a default-fallback tool card when
|
||||||
|
* the matching ``AgentActionLog`` row is reversible and hasn't been
|
||||||
|
* reverted yet.
|
||||||
|
*
|
||||||
|
* Renders ``null`` (silent) in any of these cases:
|
||||||
|
* - no matching action row (still streaming, or never logged)
|
||||||
|
* - action not reversible
|
||||||
|
* - already reverted (``reverted_by_action_id`` set)
|
||||||
|
* - this card IS itself a revert action
|
||||||
|
* - tool errored
|
||||||
|
* - no thread context
|
||||||
|
*
|
||||||
|
* 503 from the revert API means the deployment has revert gated off;
|
||||||
|
* we hide the failure silently rather than nag the user. Other errors
|
||||||
|
* surface as toasts.
|
||||||
|
*/
|
||||||
|
export function ToolCardRevertButton({
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
langchainToolCallId,
|
||||||
|
}: {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
langchainToolCallId?: string;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { threadId, action } = useToolAction({
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
langchainToolCallId,
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuiState } from "@assistant-ui/react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||||
|
import { useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the ``AgentActionLog`` row for a given tool-call card. Tries
|
||||||
|
* three lookup strategies, in priority order, against the unified
|
||||||
|
* ``useAgentActionsQuery`` cache (the same react-query cache the
|
||||||
|
* agent-actions sheet consumes — keeps the card and the sheet in
|
||||||
|
* lockstep across reload, navigation, live stream, post-stream
|
||||||
|
* reversibility flips, and explicit revert clicks).
|
||||||
|
*
|
||||||
|
* **Tier 1+2 — direct id match (O(1) Map):**
|
||||||
|
* - ``a.tool_call_id === toolCallId`` — hits when the model streamed
|
||||||
|
* ``tool_call_chunks`` so the card id matches the LangChain id.
|
||||||
|
* - ``a.tool_call_id === langchainToolCallId`` — synthetic card id
|
||||||
|
* is ``call_<run_id>`` and the LangChain id was backfilled by
|
||||||
|
* ``tool-output-available``.
|
||||||
|
*
|
||||||
|
* **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 (tool emitted as a
|
||||||
|
* single non-chunked payload AND streaming pre-dated the
|
||||||
|
* ``on_tool_end`` backfill, e.g. older threads).
|
||||||
|
*
|
||||||
|
* Returns ``null`` if no row matches OR if there's no thread context.
|
||||||
|
*
|
||||||
|
* Performance note: ``useAuiState`` returns a PRIMITIVE
|
||||||
|
* (``positionInTurn`` is a number; ``chatTurnId`` is a string) so the
|
||||||
|
* hook's ``Object.is`` short-circuit prevents re-renders on every
|
||||||
|
* text-delta of every other part in the same message during streaming.
|
||||||
|
* (See Vercel React rule ``rerender-defer-reads``.)
|
||||||
|
*/
|
||||||
|
export function useToolAction({
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
langchainToolCallId,
|
||||||
|
}: {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
langchainToolCallId?: string;
|
||||||
|
}) {
|
||||||
|
const session = useAtomValue(chatSessionStateAtom);
|
||||||
|
const threadId = session?.threadId ?? null;
|
||||||
|
const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
const direct = findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId);
|
||||||
|
if (direct) return direct;
|
||||||
|
if (!chatTurnId || positionInTurn < 0) return null;
|
||||||
|
const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName);
|
||||||
|
return turnSameTool[positionInTurn] ?? null;
|
||||||
|
}, [
|
||||||
|
findByToolCallId,
|
||||||
|
findByChatTurnAndTool,
|
||||||
|
toolCallId,
|
||||||
|
langchainToolCallId,
|
||||||
|
chatTurnId,
|
||||||
|
toolName,
|
||||||
|
positionInTurn,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { threadId, action };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { adaptItemToProps } from "./adapt-props";
|
||||||
|
export { FallbackToolBody } from "./fallback";
|
||||||
|
export { getToolComponent, TIMELINE_TOOL_NAMES } from "./registry";
|
||||||
|
export type { TimelineToolComponent, TimelineToolProps } from "./types";
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type { TimelineToolComponent } from "./types";
|
||||||
|
|
||||||
|
// Dynamic imports keep the per-tool UI bundles out of the main chunk —
|
||||||
|
// each component only loads when an assistant turn references it. Mirrors
|
||||||
|
// the existing ``components/assistant-ui/assistant-message.tsx`` pattern.
|
||||||
|
//
|
||||||
|
// Phase A note: the imported components are still typed as
|
||||||
|
// ``ToolCallMessagePartComponent`` from assistant-ui; the cast at the
|
||||||
|
// bottom of this file bridges the contract until the cutover commit
|
||||||
|
// retypes them to ``TimelineToolComponent``. The cast is a structural
|
||||||
|
// no-op — every consumed prop overlaps.
|
||||||
|
|
||||||
|
const UpdateMemoryToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const SandboxExecuteToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/sandbox-execute").then((m) => ({
|
||||||
|
default: m.SandboxExecuteToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateNotionPageToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.CreateNotionPageToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const UpdateNotionPageToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.UpdateNotionPageToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const DeleteNotionPageToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.DeleteNotionPageToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateLinearIssueToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.CreateLinearIssueToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const UpdateLinearIssueToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.UpdateLinearIssueToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const DeleteLinearIssueToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.DeleteLinearIssueToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateGoogleDriveFileToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/google-drive").then((m) => ({
|
||||||
|
default: m.CreateGoogleDriveFileToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const DeleteGoogleDriveFileToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/google-drive").then((m) => ({
|
||||||
|
default: m.DeleteGoogleDriveFileToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateOneDriveFileToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.CreateOneDriveFileToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const DeleteOneDriveFileToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.DeleteOneDriveFileToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateDropboxFileToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.CreateDropboxFileToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const DeleteDropboxFileToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.DeleteDropboxFileToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateCalendarEventToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/google-calendar").then((m) => ({
|
||||||
|
default: m.CreateCalendarEventToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const UpdateCalendarEventToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/google-calendar").then((m) => ({
|
||||||
|
default: m.UpdateCalendarEventToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const DeleteCalendarEventToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/google-calendar").then((m) => ({
|
||||||
|
default: m.DeleteCalendarEventToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateGmailDraftToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.CreateGmailDraftToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const UpdateGmailDraftToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.UpdateGmailDraftToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const SendGmailEmailToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.SendGmailEmailToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const TrashGmailEmailToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.TrashGmailEmailToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateJiraIssueToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.CreateJiraIssueToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const UpdateJiraIssueToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.UpdateJiraIssueToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const DeleteJiraIssueToolUI = dynamic(
|
||||||
|
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.DeleteJiraIssueToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const CreateConfluencePageToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/confluence").then((m) => ({
|
||||||
|
default: m.CreateConfluencePageToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const UpdateConfluencePageToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/confluence").then((m) => ({
|
||||||
|
default: m.UpdateConfluencePageToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const DeleteConfluencePageToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/confluence").then((m) => ({
|
||||||
|
default: m.DeleteConfluencePageToolUI,
|
||||||
|
})),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers-only tools — the timeline shows their ``ItemHeader`` (title +
|
||||||
|
* sub-bullets) but mounts no tool body beneath. Two reasons to use
|
||||||
|
* this:
|
||||||
|
* - **Structural primitives** (``task``): the row IS the parent of a
|
||||||
|
* delegation span; its job is to label the group. Children render
|
||||||
|
* as their own indented entries.
|
||||||
|
* - **Suppressed connectors** (``web_search``, ``link_preview``,
|
||||||
|
* ``multi_link_preview``, ``scrape_webpage``): citations they
|
||||||
|
* produce render inline in markdown; a separate card would be
|
||||||
|
* redundant noise.
|
||||||
|
*/
|
||||||
|
const NullTimelineBody: TimelineToolComponent = () => null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline's tool-name → component map. Mounted by
|
||||||
|
* ``timeline/items/tool-call-item.tsx`` via ``getToolComponent(name)``.
|
||||||
|
*
|
||||||
|
* Includes only "process" tools (connector CRUD, sandbox execute,
|
||||||
|
* memory updates) and the 4 invisible tools mapped to a null component.
|
||||||
|
* Deliverables (``generate_report``, ``generate_resume``,
|
||||||
|
* ``generate_podcast``, ``generate_video_presentation``,
|
||||||
|
* ``display_image``, ``generate_image``) live in ``BODY_TOOLS`` in
|
||||||
|
* ``assistant-message.tsx`` — they're product, not process.
|
||||||
|
*
|
||||||
|
* Tools NOT in this map fall through to ``FallbackToolBody`` (which
|
||||||
|
* itself dispatches between HITL approval cards and
|
||||||
|
* ``DefaultFallbackCard`` based on result discrimination).
|
||||||
|
*/
|
||||||
|
const TOOLS_BY_NAME = {
|
||||||
|
task: NullTimelineBody,
|
||||||
|
update_memory: UpdateMemoryToolUI,
|
||||||
|
execute: SandboxExecuteToolUI,
|
||||||
|
execute_code: SandboxExecuteToolUI,
|
||||||
|
create_notion_page: CreateNotionPageToolUI,
|
||||||
|
update_notion_page: UpdateNotionPageToolUI,
|
||||||
|
delete_notion_page: DeleteNotionPageToolUI,
|
||||||
|
create_linear_issue: CreateLinearIssueToolUI,
|
||||||
|
update_linear_issue: UpdateLinearIssueToolUI,
|
||||||
|
delete_linear_issue: DeleteLinearIssueToolUI,
|
||||||
|
create_google_drive_file: CreateGoogleDriveFileToolUI,
|
||||||
|
delete_google_drive_file: DeleteGoogleDriveFileToolUI,
|
||||||
|
create_onedrive_file: CreateOneDriveFileToolUI,
|
||||||
|
delete_onedrive_file: DeleteOneDriveFileToolUI,
|
||||||
|
create_dropbox_file: CreateDropboxFileToolUI,
|
||||||
|
delete_dropbox_file: DeleteDropboxFileToolUI,
|
||||||
|
create_calendar_event: CreateCalendarEventToolUI,
|
||||||
|
update_calendar_event: UpdateCalendarEventToolUI,
|
||||||
|
delete_calendar_event: DeleteCalendarEventToolUI,
|
||||||
|
create_gmail_draft: CreateGmailDraftToolUI,
|
||||||
|
update_gmail_draft: UpdateGmailDraftToolUI,
|
||||||
|
send_gmail_email: SendGmailEmailToolUI,
|
||||||
|
trash_gmail_email: TrashGmailEmailToolUI,
|
||||||
|
create_jira_issue: CreateJiraIssueToolUI,
|
||||||
|
update_jira_issue: UpdateJiraIssueToolUI,
|
||||||
|
delete_jira_issue: DeleteJiraIssueToolUI,
|
||||||
|
create_confluence_page: CreateConfluencePageToolUI,
|
||||||
|
update_confluence_page: UpdateConfluencePageToolUI,
|
||||||
|
delete_confluence_page: DeleteConfluencePageToolUI,
|
||||||
|
web_search: NullTimelineBody,
|
||||||
|
link_preview: NullTimelineBody,
|
||||||
|
multi_link_preview: NullTimelineBody,
|
||||||
|
scrape_webpage: NullTimelineBody,
|
||||||
|
} as unknown as Record<string, TimelineToolComponent>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup a tool component by name. Returns ``undefined`` for unknown
|
||||||
|
* tools so the caller can mount ``FallbackToolBody`` instead.
|
||||||
|
*/
|
||||||
|
export function getToolComponent(toolName: string): TimelineToolComponent | undefined {
|
||||||
|
return TOOLS_BY_NAME[toolName];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIMELINE_TOOL_NAMES = Object.keys(TOOLS_BY_NAME) as readonly string[];
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { ItemStatus } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact prop subset the timeline supplies when mounting a tool
|
||||||
|
* component. A strict subset of assistant-ui's
|
||||||
|
* ``ToolCallMessagePartProps`` — only the fields we actually have when
|
||||||
|
* rendering manually from a ``ToolCallItem``.
|
||||||
|
*
|
||||||
|
* Notably absent vs. assistant-ui:
|
||||||
|
* - ``addResult`` / ``resume`` (runtime-only, not available to us)
|
||||||
|
* - The complex ``status: ToolCallMessagePartState["status"]`` object
|
||||||
|
* (replaced by our simple ``ItemStatus`` enum)
|
||||||
|
* - ``messageId`` and other parent-message context (not needed by any
|
||||||
|
* of the 15 HITL-aware tool-ui components today)
|
||||||
|
*/
|
||||||
|
export interface TimelineToolProps {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
argsText?: string;
|
||||||
|
result?: unknown;
|
||||||
|
langchainToolCallId?: string;
|
||||||
|
status: ItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for every tool component mounted by the timeline. The 15
|
||||||
|
* existing HITL-aware ``components/tool-ui/*`` files retype to this
|
||||||
|
* during the cutover commit (a mechanical rename from
|
||||||
|
* ``ToolCallMessagePartComponent`` → ``TimelineToolComponent``).
|
||||||
|
*
|
||||||
|
* Components are expected to perform internal discrimination on
|
||||||
|
* ``result`` to pick a view (interrupt → approval card; success →
|
||||||
|
* result card; etc.) — see §2.2 of the architecture doc.
|
||||||
|
*/
|
||||||
|
export type TimelineToolComponent = (props: TimelineToolProps) => ReactNode;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue