Skip to content

Commit 7aaf5b5

Browse files
authored
feat: add WorkspaceLifecycleToolCall transcript card (#3638)
## Summary Adds `WorkspaceLifecycleToolCall`, a dedicated transcript card for the parent-owned `task_workspace_lifecycle` tool. Until now those calls fell through to the generic tool renderer; this gives orchestrator workspace cleanup a first-class, glanceable card. ## Background `task_workspace_lifecycle` (added in #3633) lets an orchestrator archive, delete the worktree of, or remove the child workspaces it created via `task(kind="workspace")`. Its result is a batch of per-target outcomes, which the generic renderer surfaced only as raw JSON. This card ports the "Workspace Lifecycle Tool Call" design from the Mux Design System into a real component. The design mockup used a simplified *placeholder* schema (`archive | delete`, a top-level `success`, a `name` field, four statuses). The real backend is different — actions are `archive | delete_worktree | remove`, and the result is `{ results: [...] }` with **no** top-level `success` and **twelve** discriminated `status` values. The card binds to the real types and ports the mockup's *visual vocabulary*, not its data shape. ## Implementation - **Collapsed** the card reads as a verb + target count + a summary pill: a single solid "N archived" pill when every target landed the same way, or severity-grouped dot-chips (settled / blocked / failed) for mixed batches. - **Expanded** it lists each target with a status-specific icon and tone, the originating `wst_…` task id (when distinct), and the real `paths` (untracked files for `requires_confirmation`), `activeTaskIds` (for `active`), and `note` / `error` fields, plus a follow-up hint for blocked states (e.g. "pass `interrupt_active: true`"). - Results are validated with `safeParse` so a malformed/partial payload self-heals to a "requested targets" fallback instead of throwing; a thrown `{ success: false, error }` routes to the shared `ErrorBox`. - The status → tone/group/icon map is an exhaustive `Record` over the backend status union, so a new backend status fails the typecheck until the card handles it. - Registers the component + a `Layers` header icon, and exports the derived arg/result/status types from `common/types/tools.ts`. - Long workspace ids truncate and path/id lists wrap (`break-all` + capped scroll), keeping the card within its container at mobile (~375px) widths. ## Validation - `make typecheck`, eslint, and `bun test src/browser/features/Tools/` (268 tests) pass. - New happy-dom test asserts the outcome-group classification, malformed-result self-healing, and the top-level error path — behavior, not card prose. - Storybook states are added with snapshots disabled (the Chromatic budget is at its ceiling); Storybook compiles and serves all states. The 375px play test is included but could not be executed locally (no Playwright browser available in this environment). ## Risks Low and isolated: a new, additive transcript renderer behind the tool registry. Worst case for a rendering bug is a single tool card degrading — and an unparseable result already falls back to the generic-style requested-targets view rather than breaking the workspace. --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 7de5542 commit 7aaf5b5

6 files changed

Lines changed: 912 additions & 0 deletions

File tree

src/browser/features/Tools/Shared/ToolPrimitives.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
GraduationCap,
2020
Hand,
2121
Keyboard,
22+
Layers,
2223
Lightbulb,
2324
MessageCircleQuestion,
2425
Monitor,
@@ -269,6 +270,9 @@ export const TOOL_NAME_TO_ICON: Partial<Record<string, LucideIcon>> = {
269270
review_pane_get: ScanEye,
270271
analytics_query: Database,
271272
task_apply_git_patch: GitCommit,
273+
// Layers (stacked planes) reads as "manage the stack of child workspaces" — matches the
274+
// WorkspaceLifecycleToolCall card's glyph.
275+
task_workspace_lifecycle: Layers,
272276
set_goal: Target,
273277
get_goal: Target,
274278
complete_goal: CircleCheck,

src/browser/features/Tools/Shared/getToolComponent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
TaskTerminateToolCall,
4343
} from "../TaskToolCall";
4444
import { TaskApplyGitPatchToolCall } from "../TaskApplyGitPatchToolCall";
45+
import { WorkspaceLifecycleToolCall } from "../WorkspaceLifecycleToolCall";
4546
import { SetGoalToolCall } from "../SetGoalToolCall";
4647
import { GetGoalToolCall } from "../GetGoalToolCall";
4748
import { HeartbeatToolCall } from "../HeartbeatToolCall";
@@ -183,6 +184,10 @@ const TOOL_REGISTRY: Record<string, ToolRegistryEntry> = {
183184
component: TaskApplyGitPatchToolCall,
184185
schema: TOOL_DEFINITIONS.task_apply_git_patch.schema,
185186
},
187+
task_workspace_lifecycle: {
188+
component: WorkspaceLifecycleToolCall,
189+
schema: TOOL_DEFINITIONS.task_workspace_lifecycle.schema,
190+
},
186191
workflow_run: {
187192
component: WorkflowRunToolCall,
188193
schema: TOOL_DEFINITIONS.workflow_run.schema,
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { WorkspaceLifecycleToolCall } from "@/browser/features/Tools/WorkspaceLifecycleToolCall";
3+
import { CHROMATIC_DISABLED, lightweightMeta, StoryUiShell } from "@/browser/stories/meta.js";
4+
5+
const meta = {
6+
...lightweightMeta,
7+
title: "App/Chat/Tools/WorkspaceLifecycle",
8+
component: WorkspaceLifecycleToolCall,
9+
parameters: {
10+
...lightweightMeta.parameters,
11+
// The repo-wide Chromatic snapshot budget (tests/ui/storybook/budget.test.ts) is already
12+
// at its ceiling, so these states stay out of paid visual snapshots. They still render
13+
// under local Storybook and the CI Storybook test-runner smoke pass. Flip to
14+
// CHROMATIC_SINGLE_MODE once the budget is raised to add regression coverage.
15+
chromatic: CHROMATIC_DISABLED,
16+
},
17+
decorators: [
18+
(Story) => (
19+
<StoryUiShell>
20+
<div className="bg-background p-6">
21+
<div className="w-full max-w-2xl">
22+
<Story />
23+
</div>
24+
</div>
25+
</StoryUiShell>
26+
),
27+
],
28+
} satisfies Meta<typeof WorkspaceLifecycleToolCall>;
29+
30+
export default meta;
31+
32+
type Story = StoryObj<typeof meta>;
33+
34+
/** archive · several owned workspaces all archived (uniform → solid "N archived" pill). */
35+
export const ArchiveMultiple: Story = {
36+
args: {
37+
args: {
38+
action: "archive",
39+
targets: [
40+
{ workspaceId: "24e33167af" },
41+
{ workspaceId: "4a92f76fbf" },
42+
{ workspaceId: "0b71c40e21" },
43+
],
44+
},
45+
status: "completed",
46+
defaultExpanded: true,
47+
result: {
48+
results: [
49+
{ status: "archived", action: "archive", workspaceId: "24e33167af" },
50+
{ status: "archived", action: "archive", workspaceId: "4a92f76fbf" },
51+
{ status: "archived", action: "archive", workspaceId: "0b71c40e21" },
52+
],
53+
},
54+
},
55+
};
56+
57+
/** remove · single workspace removed permanently (danger tone). */
58+
export const RemoveSingle: Story = {
59+
args: {
60+
args: { action: "remove", targets: [{ taskId: "wst_9f0c1d77aa" }] },
61+
status: "completed",
62+
defaultExpanded: true,
63+
result: {
64+
results: [
65+
{
66+
status: "removed",
67+
action: "remove",
68+
taskId: "wst_9f0c1d77aa",
69+
workspaceId: "9f0c1d77aa",
70+
},
71+
],
72+
},
73+
},
74+
};
75+
76+
/** delete_worktree · batch reclaim of disk for already-archived workspaces. */
77+
export const DeleteWorktreeBatch: Story = {
78+
args: {
79+
args: {
80+
action: "delete_worktree",
81+
targets: [{ workspaceId: "9f0c1d77aa" }, { workspaceId: "771be0c3d2" }],
82+
},
83+
status: "completed",
84+
defaultExpanded: true,
85+
result: {
86+
results: [
87+
{ status: "deleted_worktree", action: "delete_worktree", workspaceId: "9f0c1d77aa" },
88+
{ status: "deleted_worktree", action: "delete_worktree", workspaceId: "771be0c3d2" },
89+
],
90+
},
91+
},
92+
};
93+
94+
/** Mixed · one archived, one already archived (no-op), one not found (mixed → dot-chips). */
95+
export const PartialNotFound: Story = {
96+
args: {
97+
args: {
98+
action: "archive",
99+
targets: [
100+
{ workspaceId: "9f0c1d77aa" },
101+
{ workspaceId: "771be0c3d2" },
102+
{ workspaceId: "deadbeef00" },
103+
],
104+
},
105+
status: "completed",
106+
defaultExpanded: true,
107+
result: {
108+
results: [
109+
{ status: "archived", action: "archive", workspaceId: "9f0c1d77aa" },
110+
{ status: "already_archived", action: "archive", workspaceId: "771be0c3d2" },
111+
{
112+
status: "not_found",
113+
action: "archive",
114+
workspaceId: "deadbeef00",
115+
note: "Owned workspace metadata is already absent.",
116+
},
117+
],
118+
},
119+
},
120+
};
121+
122+
/**
123+
* Blocked · requires_confirmation (untracked files would be lost) + an active turn. Pinned
124+
* to a fixed ~375px container (the Storybook test-runner renders at desktop width and
125+
* ignores viewport / Chromatic modes, so the narrow case must be forced with a wrapper) and
126+
* a play that fails if a long workspace id / file path overflows instead of truncating.
127+
*/
128+
export const BlockedNeedsAction: Story = {
129+
args: {
130+
args: {
131+
action: "archive",
132+
targets: [
133+
{ workspaceId: "feature-billing-webhooks-experiment-very-long-id-001" },
134+
{ workspaceId: "4a92f76fbf" },
135+
],
136+
},
137+
status: "completed",
138+
defaultExpanded: true,
139+
result: {
140+
results: [
141+
{
142+
status: "requires_confirmation",
143+
action: "archive",
144+
workspaceId: "feature-billing-webhooks-experiment-very-long-id-001",
145+
paths: [
146+
"packages/server/src/very/deeply/nested/path/to/an/untracked/file/that/is/quite/long/scratch.local.ts",
147+
".env.local",
148+
],
149+
},
150+
{
151+
status: "active",
152+
action: "archive",
153+
workspaceId: "4a92f76fbf",
154+
activeTaskIds: ["wst_4a92f76fbf01"],
155+
},
156+
],
157+
},
158+
},
159+
decorators: [
160+
(Story) => (
161+
<div data-testid="wl-card-container" className="w-[375px]">
162+
<Story />
163+
</div>
164+
),
165+
],
166+
play: async ({ canvasElement }) => {
167+
if (!canvasElement.textContent?.includes("Confirm files")) {
168+
throw new Error("WorkspaceLifecycle requires_confirmation row did not render");
169+
}
170+
const container = canvasElement.querySelector('[data-testid="wl-card-container"]');
171+
if (!(container instanceof HTMLElement)) {
172+
throw new Error("WorkspaceLifecycle story container not found");
173+
}
174+
// Let layout settle before measuring.
175+
await new Promise<void>((resolve) =>
176+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
177+
);
178+
if (container.scrollWidth > container.clientWidth + 1) {
179+
throw new Error(
180+
`WorkspaceLifecycle card overflowed its ${container.clientWidth}px container by ` +
181+
`${container.scrollWidth - container.clientWidth}px`
182+
);
183+
}
184+
},
185+
};
186+
187+
/** Mid-flight, before the result arrives — falls back to the requested targets. */
188+
export const Executing: Story = {
189+
args: {
190+
args: {
191+
action: "remove",
192+
targets: [{ workspaceId: "24e33167af" }, { workspaceId: "4a92f76fbf" }],
193+
},
194+
status: "executing",
195+
defaultExpanded: true,
196+
},
197+
};
198+
199+
/**
200+
* Error · a thrown execute() surfaces as { success: false, error } (e.g. the tool ran
201+
* outside an orchestrator context). Exercises the shared ErrorBox.
202+
*/
203+
export const ErrorResult: Story = {
204+
args: {
205+
args: { action: "remove", targets: [{ workspaceId: "24e33167af" }] },
206+
status: "failed",
207+
defaultExpanded: true,
208+
result: {
209+
success: false,
210+
error: "task_workspace_lifecycle requires an orchestrator workspace context.",
211+
},
212+
},
213+
};
214+
215+
/** Per-row failure · target is not a workspace this orchestrator owns. */
216+
export const InvalidScope: Story = {
217+
args: {
218+
args: { action: "remove", targets: [{ taskId: "wst_notmine00" }] },
219+
status: "completed",
220+
defaultExpanded: true,
221+
result: {
222+
results: [{ status: "invalid_scope", action: "remove", taskId: "wst_notmine00" }],
223+
},
224+
},
225+
};

0 commit comments

Comments
 (0)