diff --git a/.changeset/collapsible-agent-block-skill.md b/.changeset/collapsible-agent-block-skill.md new file mode 100644 index 000000000..a87684674 --- /dev/null +++ b/.changeset/collapsible-agent-block-skill.md @@ -0,0 +1,5 @@ +--- +"helmor": patch +--- + +Render `context: fork` skills as collapsible agent blocks: their subagent work now folds under the Skill tool call (Skill added to AGENT_TOOL_NAMES) and the skill's result text is surfaced when it finishes. Also makes the Agent/Task block collapsible (chevron, open by default). diff --git a/src-tauri/src/pipeline/adapter/mod.rs b/src-tauri/src/pipeline/adapter/mod.rs index 33876204e..c15732474 100644 --- a/src-tauri/src/pipeline/adapter/mod.rs +++ b/src-tauri/src/pipeline/adapter/mod.rs @@ -28,7 +28,7 @@ use serde_json::Value; // Canonical tool names shared across adapter submodules. pub(crate) const PROMPT_TOOL_NAME: &str = "Prompt"; -pub(crate) const AGENT_TOOL_NAMES: &[&str] = &["Agent", "Task"]; +pub(crate) const AGENT_TOOL_NAMES: &[&str] = &["Agent", "Task", "Skill"]; use blocks::{ assistant_has_recognized_blocks, late_merge_unresolved_tool_results, merge_tool_results, diff --git a/src/features/panel/message-components/tool-call.test.tsx b/src/features/panel/message-components/tool-call.test.tsx index 9d02f749d..297d693ee 100644 --- a/src/features/panel/message-components/tool-call.test.tsx +++ b/src/features/panel/message-components/tool-call.test.tsx @@ -137,3 +137,69 @@ describe("AssistantToolCall sub-agent live tail", () => { ).not.toBeInTheDocument(); }); }); + +describe("AssistantToolCall agent block — collapsible", () => { + it("renders an open
by default and hides children when collapsed", () => { + const { container } = render( + , + ); + + const details = container.querySelector( + "details", + ) as HTMLDetailsElement | null; + expect(details).not.toBeNull(); + expect(details!.open).toBe(true); + // Child tool is visible while open. + expect(screen.getByText("foo.ts")).toBeInTheDocument(); + + // Collapse: child disappears. + details!.open = false; + fireEvent(details!, new Event("toggle")); + expect(screen.queryByText("foo.ts")).not.toBeInTheDocument(); + }); +}); + +describe("AssistantToolCall Skill tool", () => { + it("renders the Skill result text when the skill finishes", () => { + render( + , + ); + + expect(screen.getByText("SKILL OUTPUT HERE")).toBeInTheDocument(); + }); + + it("keeps the Skill block collapsible", () => { + const { container } = render( + , + ); + const details = container.querySelector( + "details", + ) as HTMLDetailsElement | null; + expect(details).not.toBeNull(); + expect(details!.open).toBe(true); + }); +}); diff --git a/src/features/panel/message-components/tool-call.tsx b/src/features/panel/message-components/tool-call.tsx index c27194f0a..78ca9778c 100644 --- a/src/features/panel/message-components/tool-call.tsx +++ b/src/features/panel/message-components/tool-call.tsx @@ -17,7 +17,6 @@ import { type CollapsedGroupPart, type ExtendedMessagePart, partKey, - type ToolCallPart, } from "@/lib/api"; import { I18nText, useI18n } from "@/lib/i18n"; import { childrenStructurallyEqual } from "@/lib/structural-equality"; @@ -25,6 +24,7 @@ import { cn } from "@/lib/utils"; import { TodoList, WorkflowCard } from "./content-parts"; import { EditDiffTrigger } from "./edit-diff"; import { + isCollapsedGroupPart, isLiveStreamingStatus, isTodoListPart, isToolCallPart, @@ -199,6 +199,7 @@ export const AssistantToolCall = memo(function AssistantToolCall({ ; + result?: unknown; streamingStatus?: string; isRunning?: boolean; parts: ExtendedMessagePart[]; @@ -474,6 +476,7 @@ export function agentChildrenBlockPropsEqual( ): boolean { return ( prev.toolName === next.toolName && + prev.result === next.result && prev.streamingStatus === next.streamingStatus && prev.isRunning === next.isRunning && childrenStructurallyEqual(prev.parts, next.parts) && @@ -484,6 +487,7 @@ export function agentChildrenBlockPropsEqual( const AgentChildrenBlock = memo(function AgentChildrenBlock({ toolName, toolArgs, + result, streamingStatus, isRunning, parts, @@ -492,12 +496,15 @@ const AgentChildrenBlock = memo(function AgentChildrenBlock({ const isLive = isLiveStreamingStatus(streamingStatus); const streaming = isLive || (!streamingStatus && !!isRunning); const info = getToolInfo(toolName, toolArgs, t, f); - const toolCallParts = useMemo( - () => - parts.filter((part): part is ToolCallPart => part.type === "tool-call"), - [parts], - ); - const toolUseCount = toolCallParts.length; + const toolUseCount = parts.reduce((count, part) => { + if (isToolCallPart(part)) { + return count + 1; + } + if (isCollapsedGroupPart(part)) { + return count + part.tools.length; + } + return count; + }, 0); // While the sub-agent is live, surface the trailing text/reasoning block // (the part currently streaming) in the collapsed preview. The collapsed // view otherwise lists only tool calls, so a streaming text turn nested @@ -505,10 +512,26 @@ const AgentChildrenBlock = memo(function AgentChildrenBlock({ const lastPart = parts[parts.length - 1]; const liveTail = streaming && lastPart && !isToolCallPart(lastPart) ? lastPart : null; + const previewFilter = + toolName === "Skill" + ? undefined + : (part: ExtendedMessagePart) => + part.type === "tool-call" || part.type === "collapsed-group"; + const finalResultText = + toolName === "Skill" && !streaming && typeof result === "string" + ? result + : null; + const [open, setOpen] = useState(true); return ( -
-
+
{ + setOpen(event.currentTarget.open); + }} + open={open} + > + {info.icon} {info.action} {info.detail ? ( @@ -527,53 +550,82 @@ const AgentChildrenBlock = memo(function AgentChildrenBlock({ ? `${toolUseCount} tool ${toolUseCount === 1 ? "use" : "uses"}` : `${parts.length} steps`} -
+ + + + + + - part.type === "tool-call"} - previewTail={liveTail} - getKey={partKey} - renderItem={(part, { expanded }) => { - if (isToolCallPart(part)) { - return ( - - ); - } - if (part.type === "text" && part.text) { - return ( -
- {part.text.slice(0, 300)} - {part.text.length > 300 ? "…" : ""} -
- ); - } - if (part.type === "reasoning" && part.text) { - return ( - - - {part.text} - - ); - } - if (isTodoListPart(part)) { - return ; - } - if (isWorkflowPart(part)) { - return ; - } - return null; - }} - /> -
+ {open ? ( + <> + { + if (isToolCallPart(part)) { + return ( + + ); + } + if (part.type === "text" && part.text) { + return ( +
+ {part.text.slice(0, 300)} + {part.text.length > 300 ? "…" : ""} +
+ ); + } + if (part.type === "reasoning" && part.text) { + return ( + + + {part.text} + + ); + } + if (isCollapsedGroupPart(part)) { + return ; + } + if (isTodoListPart(part)) { + return ; + } + if (isWorkflowPart(part)) { + return ; + } + return null; + }} + /> + {finalResultText ? ( +
+
+								{finalResultText}
+							
+
+ ) : null} + + ) : null} +
); }, agentChildrenBlockPropsEqual);