diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/adapt-props.ts b/surfsense_web/features/chat-messages/timeline/tool-registry/adapt-props.ts new file mode 100644 index 000000000..ff9671372 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/adapt-props.ts @@ -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, + }; +} diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/default-fallback-card.tsx b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/default-fallback-card.tsx new file mode 100644 index 000000000..228249ba1 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/default-fallback-card.tsx @@ -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 ( + + { + if (isRunning) return; + setIsExpanded(next); + }} + > +
+ + + + +
+ + + + +
+
+ + + +
+ {(argsText || isRunning) && ( +
+

Inputs

+ + {argsText ? ( +
+											{argsText}
+										
+ ) : ( +

+ Waiting for input… +

+ )} +
+
+ )} + {!isCancelled && result !== undefined && ( + <> + +
+

Result

+ +
+											{typeof result === "string" ? result : serializedResult}
+										
+
+
+ + )} +
+
+
+
+ ); +}; diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/fallback-tool-body.tsx b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/fallback-tool-body.tsx new file mode 100644 index 000000000..562bbfc10 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/fallback-tool-body.tsx @@ -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 ; + } + return ; + } + return ; +}; diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/index.ts b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/index.ts new file mode 100644 index 000000000..0188b45c8 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/index.ts @@ -0,0 +1 @@ +export { FallbackToolBody } from "./fallback-tool-body"; diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/revert-button.tsx b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/revert-button.tsx new file mode 100644 index 000000000..6b050523f --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/revert-button.tsx @@ -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 ( + + + + + + + Revert this action? + + This will undo{" "} + {getToolDisplayName(action.tool_name)} 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. + + + + Cancel + { + e.preventDefault(); + handleRevert(); + }} + disabled={isReverting} + className="gap-1.5" + > + {isReverting && } + Revert + + + + + ); +} diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/use-tool-action.ts b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/use-tool-action.ts new file mode 100644 index 000000000..9e34724b9 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/fallback/use-tool-action.ts @@ -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_`` 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_`` 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 }; +} diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/index.ts b/surfsense_web/features/chat-messages/timeline/tool-registry/index.ts new file mode 100644 index 000000000..212d1a7ea --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/index.ts @@ -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"; diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/registry.ts b/surfsense_web/features/chat-messages/timeline/tool-registry/registry.ts new file mode 100644 index 000000000..8acc6b4fa --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/registry.ts @@ -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; + +/** + * 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[]; diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/types.ts b/surfsense_web/features/chat-messages/timeline/tool-registry/types.ts new file mode 100644 index 000000000..8483d67c3 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/types.ts @@ -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; + 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;