Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/collapsible-agent-block-skill.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion src-tauri/src/pipeline/adapter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions src/features/panel/message-components/tool-call.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,69 @@ describe("AssistantToolCall sub-agent live tail", () => {
).not.toBeInTheDocument();
});
});

describe("AssistantToolCall agent block — collapsible", () => {
it("renders an open <details> by default and hides children when collapsed", () => {
const { container } = render(
<AssistantToolCall
toolName="Agent"
args={{ description: "Investigate" }}
result="done"
childParts={[
{
type: "tool-call",
toolCallId: "tc1",
toolName: "Read",
args: { file_path: "/src/foo.ts" },
argsText: "",
result: "contents",
},
]}
/>,
);

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(
<AssistantToolCall
toolName="Skill"
args={{ name: "commit-changes" }}
result="SKILL OUTPUT HERE"
childParts={[{ type: "text", id: "t1", text: "running skill…" }]}
/>,
);

expect(screen.getByText("SKILL OUTPUT HERE")).toBeInTheDocument();
});

it("keeps the Skill block collapsible", () => {
const { container } = render(
<AssistantToolCall
toolName="Skill"
args={{ name: "commit-changes" }}
result="done"
childParts={[{ type: "text", id: "t1", text: "skill child text" }]}
/>,
);
const details = container.querySelector(
"details",
) as HTMLDetailsElement | null;
expect(details).not.toBeNull();
expect(details!.open).toBe(true);
});
});
162 changes: 107 additions & 55 deletions src/features/panel/message-components/tool-call.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ import {
type CollapsedGroupPart,
type ExtendedMessagePart,
partKey,
type ToolCallPart,
} from "@/lib/api";
import { I18nText, useI18n } from "@/lib/i18n";
import { childrenStructurallyEqual } from "@/lib/structural-equality";
import { cn } from "@/lib/utils";
import { TodoList, WorkflowCard } from "./content-parts";
import { EditDiffTrigger } from "./edit-diff";
import {
isCollapsedGroupPart,
isLiveStreamingStatus,
isTodoListPart,
isToolCallPart,
Expand Down Expand Up @@ -199,6 +199,7 @@ export const AssistantToolCall = memo(function AssistantToolCall({
<AgentChildrenBlock
toolName={toolName}
toolArgs={args}
result={result}
streamingStatus={streamingStatus}
isRunning={result == null}
parts={childParts}
Expand Down Expand Up @@ -463,6 +464,7 @@ const AGENT_PREVIEW_STEPS = 3;
type AgentChildrenBlockProps = {
toolName: string;
toolArgs: Record<string, unknown>;
result?: unknown;
streamingStatus?: string;
isRunning?: boolean;
parts: ExtendedMessagePart[];
Expand All @@ -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) &&
Expand All @@ -484,6 +487,7 @@ export function agentChildrenBlockPropsEqual(
const AgentChildrenBlock = memo(function AgentChildrenBlock({
toolName,
toolArgs,
result,
streamingStatus,
isRunning,
parts,
Expand All @@ -492,23 +496,42 @@ 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
// into the card would render nothing until the user expands it.
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 (
<div className="flex flex-col">
<div className="flex max-w-full items-center gap-1.5 py-0.5 text-small text-muted-foreground">
<details
className="group/agent flex flex-col"
onToggle={(event) => {
setOpen(event.currentTarget.open);
}}
open={open}
>
<summary className="flex max-w-full cursor-interactive items-center gap-1.5 py-0.5 text-small text-muted-foreground [&::-webkit-details-marker]:hidden">
<span className="shrink-0">{info.icon}</span>
<span className="font-medium">{info.action}</span>
{info.detail ? (
Expand All @@ -527,53 +550,82 @@ const AgentChildrenBlock = memo(function AgentChildrenBlock({
? `${toolUseCount} tool ${toolUseCount === 1 ? "use" : "uses"}`
: `${parts.length} steps`}
</span>
</div>
<span className="shrink-0 cursor-interactive text-muted-foreground/40 hover:text-muted-foreground">
<svg
className="size-2.5 group-open/agent:rotate-90"
viewBox="0 0 12 12"
fill="none"
>
<path
d="M4.5 2.5L8.5 6L4.5 9.5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</summary>

<TruncatedToolList
items={parts}
previewCount={AGENT_PREVIEW_STEPS}
previewFilter={(part) => part.type === "tool-call"}
previewTail={liveTail}
getKey={partKey}
renderItem={(part, { expanded }) => {
if (isToolCallPart(part)) {
return (
<AssistantToolCall
toolName={part.toolName ?? "unknown"}
args={part.args ?? {}}
result={part.result}
isError={part.isError}
compact={!expanded}
childParts={part.children}
/>
);
}
if (part.type === "text" && part.text) {
return (
<div className="text-ui leading-6 text-muted-foreground">
{part.text.slice(0, 300)}
{part.text.length > 300 ? "…" : ""}
</div>
);
}
if (part.type === "reasoning" && part.text) {
return (
<Reasoning>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (isTodoListPart(part)) {
return <TodoList part={part} />;
}
if (isWorkflowPart(part)) {
return <WorkflowCard part={part} />;
}
return null;
}}
/>
</div>
{open ? (
<>
<TruncatedToolList
items={parts}
previewCount={AGENT_PREVIEW_STEPS}
previewFilter={previewFilter}
previewTail={liveTail}
getKey={partKey}
renderItem={(part, { expanded }) => {
if (isToolCallPart(part)) {
return (
<AssistantToolCall
toolName={part.toolName ?? "unknown"}
args={part.args ?? {}}
result={part.result}
isError={part.isError}
compact={!expanded}
childParts={part.children}
/>
);
}
if (part.type === "text" && part.text) {
return (
<div className="text-ui leading-6 text-muted-foreground">
{part.text.slice(0, 300)}
{part.text.length > 300 ? "…" : ""}
</div>
);
}
if (part.type === "reasoning" && part.text) {
return (
<Reasoning>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (isCollapsedGroupPart(part)) {
return <CollapsedToolGroup group={part} />;
}
if (isTodoListPart(part)) {
return <TodoList part={part} />;
}
if (isWorkflowPart(part)) {
return <WorkflowCard part={part} />;
}
return null;
}}
/>
{finalResultText ? (
<div className="ml-5 mt-1 max-h-[16rem] overflow-auto rounded-md bg-accent/35 text-mini leading-5">
<pre className="whitespace-pre-wrap break-words p-1.5 text-muted-foreground/80">
{finalResultText}
</pre>
</div>
) : null}
</>
) : null}
</details>
);
}, agentChildrenBlockPropsEqual);

Expand Down
Loading