feat: add tool rendering for read_skill, read_skill_file, start_workspace (#23744)

These tools previously fell through to the `GenericToolRenderer` which
showed a wrench icon and the raw tool name. Now they get dedicated icons
and contextual labels.

## Changes

**ToolIcon.tsx** — new icon mappings:
- `read_skill` / `read_skill_file` → `BookOpenIcon`
- `start_workspace` → `PlayIcon`
- `web_search` → `SearchIcon`

**ToolLabel.tsx** — contextual labels:
- `read_skill`: "Reading skill {name}…" → "Read skill {name}"
- `read_skill_file`: "Reading {skill}/{path}…" → "Read {skill}/{path}"
- `start_workspace`: "Starting workspace…" → "Started {name}"
- `web_search`: "Searching \"{query}\"…" → "Searched \"{query}\""

**tool.stories.tsx** — 12 new stories covering running, completed, and
error states for all four tools.

<details>
<summary>Visually verified in Storybook</summary>

All 10 story variants verified:
- ReadSkillRunning / ReadSkillCompleted / ReadSkillError
- ReadSkillFileRunning / ReadSkillFileCompleted / ReadSkillFileError
- StartWorkspaceRunning / StartWorkspaceCompleted / StartWorkspaceError
- WebSearchRunning / WebSearchCompleted / WebSearchNoQuery
</details>
This commit is contained in:
Kyle Carberry
2026-03-28 11:16:11 -04:00
committed by GitHub
parent 91217a97b9
commit a708e9d869
3 changed files with 178 additions and 0 deletions
@@ -1257,3 +1257,139 @@ export const WaitAgentComputerUseRunning: Story = {
});
},
};
// ---------------------------------------------------------------------------
// read_skill stories
// ---------------------------------------------------------------------------
export const ReadSkillRunning: Story = {
args: {
name: "read_skill",
status: "running",
args: { name: "deep-review" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Reading skill deep-review/)).toBeInTheDocument();
},
};
export const ReadSkillCompleted: Story = {
args: {
name: "read_skill",
status: "completed",
args: { name: "deep-review" },
result: {
name: "deep-review",
body: "## Deep Review Skill\n\nReview the code changes thoroughly.\n\n1. Check for correctness\n2. Verify tests\n3. Ensure style consistency",
files: ["roles/security-reviewer.md", "templates/review-checklist.md"],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Read skill deep-review/)).toBeInTheDocument();
},
};
export const ReadSkillError: Story = {
args: {
name: "read_skill",
status: "error",
isError: true,
args: { name: "nonexistent-skill" },
result: { error: 'skill "nonexistent-skill" not found' },
},
play: async ({ canvasElement }) => {
expect(
canvasElement.querySelector(".lucide-triangle-alert"),
).not.toBeNull();
},
};
// ---------------------------------------------------------------------------
// read_skill_file stories
// ---------------------------------------------------------------------------
export const ReadSkillFileRunning: Story = {
args: {
name: "read_skill_file",
status: "running",
args: { name: "deep-review", path: "roles/security-reviewer.md" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText(/Reading deep-review\/roles\/security-reviewer\.md/),
).toBeInTheDocument();
},
};
export const ReadSkillFileCompleted: Story = {
args: {
name: "read_skill_file",
status: "completed",
args: { name: "deep-review", path: "roles/security-reviewer.md" },
result: {
content:
"# Security Reviewer Role\n\nFocus on authentication, authorization, and input validation.\n\n## Checklist\n- [ ] Verify auth middleware\n- [ ] Check for SQL injection\n- [ ] Validate user inputs",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText(/Read deep-review\/roles\/security-reviewer\.md/),
).toBeInTheDocument();
},
};
export const ReadSkillFileError: Story = {
args: {
name: "read_skill_file",
status: "error",
isError: true,
args: { name: "deep-review", path: "missing-file.md" },
result: { error: "file not found" },
},
};
// ---------------------------------------------------------------------------
// start_workspace stories
// ---------------------------------------------------------------------------
export const StartWorkspaceRunning: Story = {
args: {
name: "start_workspace",
status: "running",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Starting workspace…")).toBeInTheDocument();
},
};
export const StartWorkspaceCompleted: Story = {
args: {
name: "start_workspace",
status: "completed",
result: {
started: true,
workspace_name: "my-project",
agent_status: "ready",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Started my-project")).toBeInTheDocument();
},
};
export const StartWorkspaceError: Story = {
args: {
name: "start_workspace",
status: "error",
isError: true,
result: {
error: "workspace was deleted; use create_workspace to make a new one",
},
},
};
@@ -1,9 +1,11 @@
import {
BookOpenIcon,
BotIcon,
ClipboardListIcon,
FileIcon,
FilePenIcon,
MonitorIcon,
PlayIcon,
PlusCircleIcon,
TerminalIcon,
WrenchIcon,
@@ -85,6 +87,12 @@ export const ToolIcon: React.FC<{
case "computer":
case "spawn_computer_use_agent":
return <MonitorIcon className={base} />;
case "read_skill":
case "read_skill_file":
return <BookOpenIcon className={base} />;
case "start_workspace":
return <PlayIcon className={base} />;
default:
return <WrenchIcon className={base} />;
}
@@ -170,6 +170,40 @@ export const ToolLabel: React.FC<{
</span>
);
}
case "read_skill": {
const skillName = parsed ? asString(parsed.name) : "";
return (
<span className="truncate text-sm text-content-secondary">
{skillName
? parsedResult
? `Read skill ${skillName}`
: `Reading skill ${skillName}…`
: "Reading skill…"}
</span>
);
}
case "read_skill_file": {
const skillName = parsed ? asString(parsed.name) : "";
const filePath = parsed ? asString(parsed.path) : "";
const label =
skillName && filePath
? `${skillName}/${filePath}`
: skillName || filePath || "skill file";
return (
<span className="truncate text-sm text-content-secondary">
{parsedResult ? `Read ${label}` : `Reading ${label}…`}
</span>
);
}
case "start_workspace": {
const wsName = parsedResult ? asString(parsedResult.workspace_name) : "";
return (
<span className="truncate text-sm text-content-secondary">
{wsName ? `Started ${wsName}` : "Starting workspace…"}
</span>
);
}
default: {
const displayName = mcpSlug ? humanizeMCPToolName(mcpSlug, name) : name;
return (