From e7c5204b0248d7ceb6262478a4419eb7e0fe6f58 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 9 May 2026 00:39:59 +0200 Subject: [PATCH] Indent tool cards under an active delegating task span. --- .../assistant-ui/assistant-message.tsx | 88 ++++++++++--------- .../components/assistant-ui/tool-fallback.tsx | 22 +++++ .../components/public-chat/public-thread.tsx | 29 +++--- .../lib/chat/delegation-span-indent.ts | 19 ++++ 4 files changed, 106 insertions(+), 52 deletions(-) create mode 100644 surfsense_web/lib/chat/delegation-span-indent.ts diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 7bccc22ee..a21ade74a 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -4,6 +4,7 @@ import { AuiIf, ErrorPrimitive, MessagePrimitive, + type ToolCallMessagePartComponent, useAui, useAuiState, } from "@assistant-ui/react"; @@ -36,7 +37,7 @@ import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part"; import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button"; import { useTokenUsage } from "@/components/assistant-ui/token-usage-context"; -import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { ToolFallback, withDelegationSpanIndent } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; @@ -505,48 +506,55 @@ const MessageInfoDropdown: FC = () => { // Wrap each tool-ui card with ``withBundleStep`` so multi-card HITL bundles // 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. +const bundleTool = (Component: ToolCallMessagePartComponent) => + withBundleStep(withDelegationSpanIndent(Component)); + +const NullToolUi: ToolCallMessagePartComponent = () => null; + const TOOLS_BY_NAME = { - generate_report: withBundleStep(GenerateReportToolUI), - generate_resume: withBundleStep(GenerateResumeToolUI), - generate_podcast: withBundleStep(GeneratePodcastToolUI), - generate_video_presentation: withBundleStep(GenerateVideoPresentationToolUI), - display_image: withBundleStep(GenerateImageToolUI), - generate_image: withBundleStep(GenerateImageToolUI), - update_memory: withBundleStep(UpdateMemoryToolUI), - execute: withBundleStep(SandboxExecuteToolUI), - execute_code: withBundleStep(SandboxExecuteToolUI), - create_notion_page: withBundleStep(CreateNotionPageToolUI), - update_notion_page: withBundleStep(UpdateNotionPageToolUI), - delete_notion_page: withBundleStep(DeleteNotionPageToolUI), - create_linear_issue: withBundleStep(CreateLinearIssueToolUI), - update_linear_issue: withBundleStep(UpdateLinearIssueToolUI), - delete_linear_issue: withBundleStep(DeleteLinearIssueToolUI), - create_google_drive_file: withBundleStep(CreateGoogleDriveFileToolUI), - delete_google_drive_file: withBundleStep(DeleteGoogleDriveFileToolUI), - create_onedrive_file: withBundleStep(CreateOneDriveFileToolUI), - delete_onedrive_file: withBundleStep(DeleteOneDriveFileToolUI), - create_dropbox_file: withBundleStep(CreateDropboxFileToolUI), - delete_dropbox_file: withBundleStep(DeleteDropboxFileToolUI), - create_calendar_event: withBundleStep(CreateCalendarEventToolUI), - update_calendar_event: withBundleStep(UpdateCalendarEventToolUI), - delete_calendar_event: withBundleStep(DeleteCalendarEventToolUI), - create_gmail_draft: withBundleStep(CreateGmailDraftToolUI), - update_gmail_draft: withBundleStep(UpdateGmailDraftToolUI), - send_gmail_email: withBundleStep(SendGmailEmailToolUI), - trash_gmail_email: withBundleStep(TrashGmailEmailToolUI), - create_jira_issue: withBundleStep(CreateJiraIssueToolUI), - update_jira_issue: withBundleStep(UpdateJiraIssueToolUI), - delete_jira_issue: withBundleStep(DeleteJiraIssueToolUI), - create_confluence_page: withBundleStep(CreateConfluencePageToolUI), - update_confluence_page: withBundleStep(UpdateConfluencePageToolUI), - delete_confluence_page: withBundleStep(DeleteConfluencePageToolUI), - web_search: () => null, - link_preview: () => null, - multi_link_preview: () => null, - scrape_webpage: () => null, + generate_report: bundleTool(GenerateReportToolUI), + generate_resume: bundleTool(GenerateResumeToolUI), + generate_podcast: bundleTool(GeneratePodcastToolUI), + generate_video_presentation: bundleTool(GenerateVideoPresentationToolUI), + display_image: bundleTool(GenerateImageToolUI), + generate_image: bundleTool(GenerateImageToolUI), + update_memory: bundleTool(UpdateMemoryToolUI), + execute: bundleTool(SandboxExecuteToolUI), + execute_code: bundleTool(SandboxExecuteToolUI), + create_notion_page: bundleTool(CreateNotionPageToolUI), + update_notion_page: bundleTool(UpdateNotionPageToolUI), + delete_notion_page: bundleTool(DeleteNotionPageToolUI), + create_linear_issue: bundleTool(CreateLinearIssueToolUI), + update_linear_issue: bundleTool(UpdateLinearIssueToolUI), + delete_linear_issue: bundleTool(DeleteLinearIssueToolUI), + create_google_drive_file: bundleTool(CreateGoogleDriveFileToolUI), + delete_google_drive_file: bundleTool(DeleteGoogleDriveFileToolUI), + create_onedrive_file: bundleTool(CreateOneDriveFileToolUI), + delete_onedrive_file: bundleTool(DeleteOneDriveFileToolUI), + create_dropbox_file: bundleTool(CreateDropboxFileToolUI), + delete_dropbox_file: bundleTool(DeleteDropboxFileToolUI), + create_calendar_event: bundleTool(CreateCalendarEventToolUI), + update_calendar_event: bundleTool(UpdateCalendarEventToolUI), + delete_calendar_event: bundleTool(DeleteCalendarEventToolUI), + create_gmail_draft: bundleTool(CreateGmailDraftToolUI), + update_gmail_draft: bundleTool(UpdateGmailDraftToolUI), + send_gmail_email: bundleTool(SendGmailEmailToolUI), + trash_gmail_email: bundleTool(TrashGmailEmailToolUI), + create_jira_issue: bundleTool(CreateJiraIssueToolUI), + update_jira_issue: bundleTool(UpdateJiraIssueToolUI), + delete_jira_issue: bundleTool(DeleteJiraIssueToolUI), + create_confluence_page: bundleTool(CreateConfluencePageToolUI), + update_confluence_page: bundleTool(UpdateConfluencePageToolUI), + delete_confluence_page: bundleTool(DeleteConfluencePageToolUI), + web_search: NullToolUi, + link_preview: NullToolUi, + multi_link_preview: NullToolUi, + scrape_webpage: NullToolUi, } as const; -const TOOLS_FALLBACK = withBundleStep(ToolFallback); +const TOOLS_FALLBACK = bundleTool(ToolFallback); const AssistantMessageInner: FC = () => { const isMobile = !useMediaQuery("(min-width: 768px)"); diff --git a/surfsense_web/components/assistant-ui/tool-fallback.tsx b/surfsense_web/components/assistant-ui/tool-fallback.tsx index ba58f4158..ec93b1018 100644 --- a/surfsense_web/components/assistant-ui/tool-fallback.tsx +++ b/surfsense_web/components/assistant-ui/tool-fallback.tsx @@ -31,6 +31,10 @@ 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"; @@ -499,6 +503,24 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => { ); }; +/** + * 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 }).metadata; + const indent = shouldIndentToolCallForDelegationSpan(props.toolName, metadata); + const inner = ; + return indent ?
{inner}
: inner; + }; + Wrapped.displayName = `withDelegationSpanIndent(${Component.displayName ?? Component.name ?? "ToolUI"})`; + return Wrapped; +} + export const ToolFallback: ToolCallMessagePartComponent = (props) => { if (isInterruptResult(props.result)) { if (isDoomLoopInterrupt(props.result)) { diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index 750b7410e..2075d82b8 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -5,6 +5,7 @@ import { AuiIf, MessagePrimitive, ThreadPrimitive, + type ToolCallMessagePartComponent, useAuiState, } from "@assistant-ui/react"; import { CheckIcon, CopyIcon } from "lucide-react"; @@ -14,7 +15,7 @@ import { type FC, type ReactNode, useState } from "react"; import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part"; -import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { ToolFallback, withDelegationSpanIndent } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; @@ -29,6 +30,8 @@ const GenerateVideoPresentationToolUI = dynamic( { ssr: false } ); +const NullToolUi: ToolCallMessagePartComponent = () => null; + interface PublicThreadProps { footer?: ReactNode; } @@ -162,18 +165,20 @@ const PublicAssistantMessage: FC = () => { Reasoning: ReasoningMessagePart, tools: { by_name: { - generate_podcast: GeneratePodcastToolUI, - generate_report: GenerateReportToolUI, - generate_resume: GenerateResumeToolUI, - generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: GenerateImageToolUI, - generate_image: GenerateImageToolUI, - web_search: () => null, - link_preview: () => null, - multi_link_preview: () => null, - scrape_webpage: () => null, + generate_podcast: withDelegationSpanIndent(GeneratePodcastToolUI), + generate_report: withDelegationSpanIndent(GenerateReportToolUI), + generate_resume: withDelegationSpanIndent(GenerateResumeToolUI), + generate_video_presentation: withDelegationSpanIndent( + GenerateVideoPresentationToolUI + ), + display_image: withDelegationSpanIndent(GenerateImageToolUI), + generate_image: withDelegationSpanIndent(GenerateImageToolUI), + web_search: NullToolUi, + link_preview: NullToolUi, + multi_link_preview: NullToolUi, + scrape_webpage: NullToolUi, }, - Fallback: ToolFallback, + Fallback: withDelegationSpanIndent(ToolFallback), }, }} /> diff --git a/surfsense_web/lib/chat/delegation-span-indent.ts b/surfsense_web/lib/chat/delegation-span-indent.ts new file mode 100644 index 000000000..99e292eaf --- /dev/null +++ b/surfsense_web/lib/chat/delegation-span-indent.ts @@ -0,0 +1,19 @@ +/** + * Indent tool-call cards that belong to an open delegating ``task`` episode. + * + * The backend only stamps ``metadata.spanId`` on tool SSE / persisted parts + * while a ``task`` is active (see ``AgentEventRelayState.tool_activity_metadata``), + * so its presence is sufficient. The opening ``task`` row itself carries the + * same span id but stays flush — it is the header of the delegation. + */ + +export function shouldIndentToolCallForDelegationSpan( + toolName: string, + metadata: Record | undefined +): boolean { + if (toolName === "task") return false; + const v = metadata?.spanId; + return typeof v === "string" && v.trim().length > 0; +} + +export const DELEGATION_SPAN_INDENT_CLASS = "pl-3 sm:ml-4";