From a8417e3c451090da6d05e54ef152b3ff3c72757a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 9 May 2026 14:37:06 +0200 Subject: [PATCH] Render HITL approval cards inline in the thinking-steps timeline. --- .../assistant-ui/assistant-message.tsx | 17 ++- .../assistant-ui/thinking-steps.tsx | 101 ++++++++++++++++-- surfsense_web/lib/hitl/index.ts | 6 ++ surfsense_web/lib/hitl/render-target.tsx | 48 +++++++++ 4 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 surfsense_web/lib/hitl/render-target.tsx diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index a21ade74a..549141779 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -59,6 +59,7 @@ import { DropdownMenuLabel } from "@/components/ui/dropdown-menu"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; +import { withHitlInTimeline } from "@/lib/hitl"; import { getProviderIcon } from "@/lib/provider-icons"; import { cn } from "@/lib/utils"; @@ -508,12 +509,22 @@ const MessageInfoDropdown: FC = () => { // page through them and stage decisions instead of firing one resume per card. // ``withDelegationSpanIndent`` wraps every entry (including Fallback) so delegated // subagent tools don't bypass span indentation via a named ``by_name`` UI. +// ``withHitlInTimeline`` is the OUTERMOST wrapper so a body render with an +// interrupt result returns ``null`` immediately — no inner wrappers paint +// — while a timeline render (under ``HitlRenderTargetProvider value="timeline"`` +// inside ``ThinkingStepsDisplay``) passes through to the real component. const bundleTool = (Component: ToolCallMessagePartComponent) => - withBundleStep(withDelegationSpanIndent(Component)); + withHitlInTimeline(withBundleStep(withDelegationSpanIndent(Component))); const NullToolUi: ToolCallMessagePartComponent = () => null; -const TOOLS_BY_NAME = { +/** + * Tool-call UI registry. Exported so ``ThinkingStepsDisplay`` can mount + * the SAME wrapped components inline under a step row when the card's + * result is an HITL interrupt. The wrappers handle ``ToolCallIdProvider`` + * and bundle paging consistently across both render targets. + */ +export const TOOLS_BY_NAME = { generate_report: bundleTool(GenerateReportToolUI), generate_resume: bundleTool(GenerateResumeToolUI), generate_podcast: bundleTool(GeneratePodcastToolUI), @@ -554,7 +565,7 @@ const TOOLS_BY_NAME = { scrape_webpage: NullToolUi, } as const; -const TOOLS_FALLBACK = bundleTool(ToolFallback); +export const TOOLS_FALLBACK = bundleTool(ToolFallback); const AssistantMessageInner: FC = () => { const isMobile = !useMediaQuery("(min-width: 768px)"); diff --git a/surfsense_web/components/assistant-ui/thinking-steps.tsx b/surfsense_web/components/assistant-ui/thinking-steps.tsx index 6c3832bff..46d33a9fa 100644 --- a/surfsense_web/components/assistant-ui/thinking-steps.tsx +++ b/surfsense_web/components/assistant-ui/thinking-steps.tsx @@ -1,9 +1,15 @@ -import { makeAssistantDataUI, useAuiState } from "@assistant-ui/react"; +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 { @@ -24,20 +30,33 @@ export interface ThinkingStep { * 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; + argsText?: string; + result?: unknown; + langchainToolCallId?: string; } export type ThinkingStepToolInfoMap = ReadonlyMap; /** - * Build ``thinkingStepId → {toolName, args}`` from message content. Used to + * 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. + * 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 @@ -48,14 +67,25 @@ export function buildThinkingStepToolInfo( if (!part || typeof part !== "object") continue; const o = part as { type?: string; + toolCallId?: string; toolName?: string; args?: Record; + argsText?: string; + result?: unknown; + langchainToolCallId?: string; metadata?: Record; }; - if (o.type !== "tool-call" || !o.toolName) continue; + 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, { toolName: o.toolName, args: o.args ?? {} }); + m.set(tid, { + toolCallId: o.toolCallId, + toolName: o.toolName, + args: o.args ?? {}, + argsText: o.argsText, + result: o.result, + langchainToolCallId: o.langchainToolCallId, + }); } } return m; @@ -159,6 +189,47 @@ const StepBody: FC<{ ); +/** + * 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)[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 ( + + {/* 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. */} + + + ); +}; + +function hitlInterruptInfo(info: StepToolInfo | undefined): StepToolInfo | undefined { + return info && isInterruptResult(info.result) ? info : undefined; +} + /** * Chain of thought display component - single collapsible dropdown design. * @@ -291,17 +362,25 @@ export const ThinkingStepsDisplay: FC<{ displayTitle={parentTitle} /> + {(() => { + const hitl = hitlInterruptInfo(parentInfo); + return hitl ? : null; + })()} + {hasChildren && (
{group.children.map((child) => { const childInfo = toolInfo.get(child.id); + const childHitl = hitlInterruptInfo(childInfo); return ( - +
+ + {childHitl && } +
); })}
diff --git a/surfsense_web/lib/hitl/index.ts b/surfsense_web/lib/hitl/index.ts index 4bb15e8b5..a2f218d5d 100644 --- a/surfsense_web/lib/hitl/index.ts +++ b/surfsense_web/lib/hitl/index.ts @@ -6,6 +6,12 @@ export { useHitlBundle, useToolCallIdContext, } from "./bundle-context"; +export { + type HitlRenderTarget, + HitlRenderTargetProvider, + useHitlRenderTarget, + withHitlInTimeline, +} from "./render-target"; export type { HitlDecision, InterruptActionRequest, diff --git a/surfsense_web/lib/hitl/render-target.tsx b/surfsense_web/lib/hitl/render-target.tsx new file mode 100644 index 000000000..cbfdbf2be --- /dev/null +++ b/surfsense_web/lib/hitl/render-target.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { ToolCallMessagePartComponent } from "@assistant-ui/react"; +import { createContext, useContext } from "react"; +import { isInterruptResult } from "./types"; + +/** + * Where this tool-call card is currently rendering. + * + * - ``"body"`` (default) — assistant-ui's ``MessagePrimitive.Parts`` renders + * the card inside the message bubble. + * - ``"timeline"`` — ``ThinkingStepsDisplay`` renders the SAME component + * inline under the matching step row so the HITL approval lives in the + * chain-of-thought instead of as a standalone card in the message body. + * + * The two render targets share one component implementation; the context + * lets the body render skip itself when the timeline copy will show the + * card, avoiding a double-render. + */ +export type HitlRenderTarget = "body" | "timeline"; + +const HitlRenderTargetContext = createContext("body"); + +export const HitlRenderTargetProvider = HitlRenderTargetContext.Provider; + +export function useHitlRenderTarget(): HitlRenderTarget { + return useContext(HitlRenderTargetContext); +} + +/** + * Hide the body render of a tool-call whose result is a HITL interrupt. + * The same component is mounted again inside ``ThinkingStepsDisplay`` + * with ``HitlRenderTargetProvider value="timeline"`` — that copy renders + * normally, so the card "moves" from the message body to the timeline. + * + * Pure pass-through for non-HITL results AND for the timeline render. + */ +export function withHitlInTimeline( + Component: ToolCallMessagePartComponent +): ToolCallMessagePartComponent { + const Wrapped: ToolCallMessagePartComponent = (props) => { + const target = useHitlRenderTarget(); + if (target === "body" && isInterruptResult(props.result)) return null; + return ; + }; + Wrapped.displayName = `withHitlInTimeline(${Component.displayName ?? Component.name ?? "ToolUI"})`; + return Wrapped; +}