Indent tool cards under an active delegating task span.

This commit is contained in:
CREDO23 2026-05-09 00:39:59 +02:00
parent 39084b3075
commit e7c5204b02
4 changed files with 106 additions and 52 deletions

View file

@ -4,6 +4,7 @@ import {
AuiIf, AuiIf,
ErrorPrimitive, ErrorPrimitive,
MessagePrimitive, MessagePrimitive,
type ToolCallMessagePartComponent,
useAui, useAui,
useAuiState, useAuiState,
} from "@assistant-ui/react"; } 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 { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button"; import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context"; 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 { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container"; import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet"; 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 // 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. // 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 = { const TOOLS_BY_NAME = {
generate_report: withBundleStep(GenerateReportToolUI), generate_report: bundleTool(GenerateReportToolUI),
generate_resume: withBundleStep(GenerateResumeToolUI), generate_resume: bundleTool(GenerateResumeToolUI),
generate_podcast: withBundleStep(GeneratePodcastToolUI), generate_podcast: bundleTool(GeneratePodcastToolUI),
generate_video_presentation: withBundleStep(GenerateVideoPresentationToolUI), generate_video_presentation: bundleTool(GenerateVideoPresentationToolUI),
display_image: withBundleStep(GenerateImageToolUI), display_image: bundleTool(GenerateImageToolUI),
generate_image: withBundleStep(GenerateImageToolUI), generate_image: bundleTool(GenerateImageToolUI),
update_memory: withBundleStep(UpdateMemoryToolUI), update_memory: bundleTool(UpdateMemoryToolUI),
execute: withBundleStep(SandboxExecuteToolUI), execute: bundleTool(SandboxExecuteToolUI),
execute_code: withBundleStep(SandboxExecuteToolUI), execute_code: bundleTool(SandboxExecuteToolUI),
create_notion_page: withBundleStep(CreateNotionPageToolUI), create_notion_page: bundleTool(CreateNotionPageToolUI),
update_notion_page: withBundleStep(UpdateNotionPageToolUI), update_notion_page: bundleTool(UpdateNotionPageToolUI),
delete_notion_page: withBundleStep(DeleteNotionPageToolUI), delete_notion_page: bundleTool(DeleteNotionPageToolUI),
create_linear_issue: withBundleStep(CreateLinearIssueToolUI), create_linear_issue: bundleTool(CreateLinearIssueToolUI),
update_linear_issue: withBundleStep(UpdateLinearIssueToolUI), update_linear_issue: bundleTool(UpdateLinearIssueToolUI),
delete_linear_issue: withBundleStep(DeleteLinearIssueToolUI), delete_linear_issue: bundleTool(DeleteLinearIssueToolUI),
create_google_drive_file: withBundleStep(CreateGoogleDriveFileToolUI), create_google_drive_file: bundleTool(CreateGoogleDriveFileToolUI),
delete_google_drive_file: withBundleStep(DeleteGoogleDriveFileToolUI), delete_google_drive_file: bundleTool(DeleteGoogleDriveFileToolUI),
create_onedrive_file: withBundleStep(CreateOneDriveFileToolUI), create_onedrive_file: bundleTool(CreateOneDriveFileToolUI),
delete_onedrive_file: withBundleStep(DeleteOneDriveFileToolUI), delete_onedrive_file: bundleTool(DeleteOneDriveFileToolUI),
create_dropbox_file: withBundleStep(CreateDropboxFileToolUI), create_dropbox_file: bundleTool(CreateDropboxFileToolUI),
delete_dropbox_file: withBundleStep(DeleteDropboxFileToolUI), delete_dropbox_file: bundleTool(DeleteDropboxFileToolUI),
create_calendar_event: withBundleStep(CreateCalendarEventToolUI), create_calendar_event: bundleTool(CreateCalendarEventToolUI),
update_calendar_event: withBundleStep(UpdateCalendarEventToolUI), update_calendar_event: bundleTool(UpdateCalendarEventToolUI),
delete_calendar_event: withBundleStep(DeleteCalendarEventToolUI), delete_calendar_event: bundleTool(DeleteCalendarEventToolUI),
create_gmail_draft: withBundleStep(CreateGmailDraftToolUI), create_gmail_draft: bundleTool(CreateGmailDraftToolUI),
update_gmail_draft: withBundleStep(UpdateGmailDraftToolUI), update_gmail_draft: bundleTool(UpdateGmailDraftToolUI),
send_gmail_email: withBundleStep(SendGmailEmailToolUI), send_gmail_email: bundleTool(SendGmailEmailToolUI),
trash_gmail_email: withBundleStep(TrashGmailEmailToolUI), trash_gmail_email: bundleTool(TrashGmailEmailToolUI),
create_jira_issue: withBundleStep(CreateJiraIssueToolUI), create_jira_issue: bundleTool(CreateJiraIssueToolUI),
update_jira_issue: withBundleStep(UpdateJiraIssueToolUI), update_jira_issue: bundleTool(UpdateJiraIssueToolUI),
delete_jira_issue: withBundleStep(DeleteJiraIssueToolUI), delete_jira_issue: bundleTool(DeleteJiraIssueToolUI),
create_confluence_page: withBundleStep(CreateConfluencePageToolUI), create_confluence_page: bundleTool(CreateConfluencePageToolUI),
update_confluence_page: withBundleStep(UpdateConfluencePageToolUI), update_confluence_page: bundleTool(UpdateConfluencePageToolUI),
delete_confluence_page: withBundleStep(DeleteConfluencePageToolUI), delete_confluence_page: bundleTool(DeleteConfluencePageToolUI),
web_search: () => null, web_search: NullToolUi,
link_preview: () => null, link_preview: NullToolUi,
multi_link_preview: () => null, multi_link_preview: NullToolUi,
scrape_webpage: () => null, scrape_webpage: NullToolUi,
} as const; } as const;
const TOOLS_FALLBACK = withBundleStep(ToolFallback); const TOOLS_FALLBACK = bundleTool(ToolFallback);
const AssistantMessageInner: FC = () => { const AssistantMessageInner: FC = () => {
const isMobile = !useMediaQuery("(min-width: 768px)"); const isMobile = !useMediaQuery("(min-width: 768px)");

View file

@ -31,6 +31,10 @@ import { Spinner } from "@/components/ui/spinner";
import { getToolDisplayName } from "@/contracts/enums/toolIcons"; import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { markActionRevertedInCache, useAgentActionsQuery } from "@/hooks/use-agent-actions-query"; import { markActionRevertedInCache, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service"; 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 { AppError } from "@/lib/error";
import { isInterruptResult } from "@/lib/hitl"; import { isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils"; 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<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) => { export const ToolFallback: ToolCallMessagePartComponent = (props) => {
if (isInterruptResult(props.result)) { if (isInterruptResult(props.result)) {
if (isDoomLoopInterrupt(props.result)) { if (isDoomLoopInterrupt(props.result)) {

View file

@ -5,6 +5,7 @@ import {
AuiIf, AuiIf,
MessagePrimitive, MessagePrimitive,
ThreadPrimitive, ThreadPrimitive,
type ToolCallMessagePartComponent,
useAuiState, useAuiState,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { CheckIcon, CopyIcon } from "lucide-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 { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part"; 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 { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
@ -29,6 +30,8 @@ const GenerateVideoPresentationToolUI = dynamic(
{ ssr: false } { ssr: false }
); );
const NullToolUi: ToolCallMessagePartComponent = () => null;
interface PublicThreadProps { interface PublicThreadProps {
footer?: ReactNode; footer?: ReactNode;
} }
@ -162,18 +165,20 @@ const PublicAssistantMessage: FC = () => {
Reasoning: ReasoningMessagePart, Reasoning: ReasoningMessagePart,
tools: { tools: {
by_name: { by_name: {
generate_podcast: GeneratePodcastToolUI, generate_podcast: withDelegationSpanIndent(GeneratePodcastToolUI),
generate_report: GenerateReportToolUI, generate_report: withDelegationSpanIndent(GenerateReportToolUI),
generate_resume: GenerateResumeToolUI, generate_resume: withDelegationSpanIndent(GenerateResumeToolUI),
generate_video_presentation: GenerateVideoPresentationToolUI, generate_video_presentation: withDelegationSpanIndent(
display_image: GenerateImageToolUI, GenerateVideoPresentationToolUI
generate_image: GenerateImageToolUI, ),
web_search: () => null, display_image: withDelegationSpanIndent(GenerateImageToolUI),
link_preview: () => null, generate_image: withDelegationSpanIndent(GenerateImageToolUI),
multi_link_preview: () => null, web_search: NullToolUi,
scrape_webpage: () => null, link_preview: NullToolUi,
multi_link_preview: NullToolUi,
scrape_webpage: NullToolUi,
}, },
Fallback: ToolFallback, Fallback: withDelegationSpanIndent(ToolFallback),
}, },
}} }}
/> />

View file

@ -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<string, unknown> | 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";