Skip to content

Commit 04ad0e9

Browse files
committed
feat(web): expand icon button action coverage
1 parent d7a7ae0 commit 04ad0e9

11 files changed

Lines changed: 420 additions & 102 deletions

File tree

packages/web/src/components/ui/MIGRATION.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
| Component | Status | Legacy classes | Callers left | Last update |
44
|---|---|---|---:|---|
55
| Button | 🟢 complete | `.btn .btn-*` | 0 | 2026-05-09 |
6-
| IconButton | 🟡 partial | `.btn` icon-only | bounded modal/dialog close, desktop/mobile topbar icon-only triggers, workspace fullscreen, supervisor card actions, selected terminal/workspace flows, shortcut reset, and workspace file-toolbar actions covered; broader icon-action families remain | 2026-05-09 |
6+
| IconButton | 🟡 partial | `.btn` icon-only | bounded modal/dialog close, desktop/mobile topbar icon-only triggers, workspace fullscreen, supervisor card actions, git/file-tree row actions, mobile-select trailing side actions, agent session header actions, selected terminal/workspace flows, shortcut reset, and workspace file-toolbar actions covered; broader icon-action families remain | 2026-05-09 |
77
| Input | 🟢 complete | `.input` | 0 | 2026-05-09 |
88
| Textarea | 🟢 complete | `.input.textarea` | 0 | 2026-05-09 |
99
| Tag | 🟢 complete | `.badge .badge-*` | 0 | 2026-05-09 |
@@ -31,7 +31,7 @@
3131

3232
`Button` now completes the legacy `.btn` / `.btn-*` migration inventory: the remaining worktree summary/manage flows now use the shared primitive from the public UI barrel, and the previous bounded migrations across auth, config actions, supervisor dialogs, git flows, file-tree dialogs, notifications, and shared mobile/desktop shells leave no feature-local raw `.btn` callsites behind. Legacy compatibility classes remain emitted by the shared primitive for zero-regression styling while ownership stays in the component.
3333

34-
`IconButton` now covers a broader bounded icon-action slice: the desktop topbar add/settings/files/terminal triggers, the shared workspace fullscreen control, the mobile topbar more-actions/fullscreen triggers, and the supervisor card edit/pause-resume/trigger/disable controls all use the shared primitive from the public UI barrel while preserving caller-owned compatibility classes such as `topbar-add`, `topbar-btn*`, `mobile-topbar__icon-button`, and `supervisor-icon-btn*`. Broader deferred icon-action families remain intentionally outside this slice.
34+
`IconButton` now covers a broader bounded icon-action slice: the desktop topbar add/settings/files/terminal triggers, the shared workspace fullscreen control, the mobile topbar more-actions/fullscreen triggers, the supervisor card edit/pause-resume/trigger/disable controls, the git-panel row stage/unstage/discard actions, the file-tree search/tree-row create-delete actions, the mobile-select trailing side actions, and the agent session-card / draft-launcher header controls all use the shared primitive from the public UI barrel while preserving caller-owned compatibility classes such as `topbar-add`, `topbar-btn*`, `mobile-topbar__icon-button`, `supervisor-icon-btn*`, `git-row-action`, `mobile-select-sheet__item-side-action*`, and `session-action-btn*`. Broader deferred icon-action families remain intentionally outside this slice.
3535

3636
`Textarea` now completes the bounded legacy `.input.textarea` migration inventory: the provider startup-args textarea and the supervisor objective textarea both use the shared primitive from the public UI barrel while preserving legacy `input` / `textarea` compatibility classes and caller-owned hooks such as `settings-provider-args-input`. The primitive also supports optional auto-resize for later adopters. The `git-panel` commit message field is intentionally not counted on this row because it is not part of the `.input.textarea` family selected for this slice, and standalone `.textarea` utility usage remains outside this row.
3737

packages/web/src/features/agent-panes/components/session-card.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,46 @@ describe("SessionCard", () => {
498498
expect(onSplitVertical).toHaveBeenCalledTimes(1);
499499
});
500500

501+
it("uses shared IconButton compatibility classes for header actions", () => {
502+
const { store } = createSessionStore({
503+
terminalId: "term-live",
504+
state: "running",
505+
endedAt: undefined,
506+
});
507+
508+
render(
509+
<Provider store={store}>
510+
<SessionCard sessionId="sess_123456" />
511+
</Provider>
512+
);
513+
514+
expect(screen.getByRole("button", { name: "Stop" })).toHaveClass(
515+
"btn",
516+
"btn-ghost",
517+
"btn-sm",
518+
"session-action-btn"
519+
);
520+
expect(screen.getByRole("button", { name: "Split horizontal" })).toHaveClass(
521+
"btn",
522+
"btn-ghost",
523+
"btn-sm",
524+
"session-action-btn"
525+
);
526+
expect(screen.getByRole("button", { name: "Split vertical" })).toHaveClass(
527+
"btn",
528+
"btn-ghost",
529+
"btn-sm",
530+
"session-action-btn"
531+
);
532+
expect(screen.getByRole("button", { name: "Close" })).toHaveClass(
533+
"btn",
534+
"btn-ghost",
535+
"btn-sm",
536+
"session-action-btn",
537+
"session-action-btn-close"
538+
);
539+
});
540+
501541
it("persists activeSessionId when the card is clicked", async () => {
502542
const sendCommand = vi.fn().mockResolvedValue({
503543
ok: true,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { createStore, Provider } from "jotai";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { localeAtom } from "../../../../atoms/app-ui";
5+
import { wsClientAtom } from "../../../../atoms/connection";
6+
import { DraftLauncher } from "./draft-launcher";
7+
8+
const mockUseProviderLauncher = vi.fn();
9+
10+
vi.mock("../../actions/use-provider-launcher", () => ({
11+
useProviderLauncher: (...args: unknown[]) => mockUseProviderLauncher(...args),
12+
}));
13+
14+
function createRuntimeState(providerId: "claude" | "codex") {
15+
return {
16+
runtime: {
17+
providerId,
18+
available: true,
19+
missingCommands: [],
20+
missingPrerequisites: [],
21+
autoInstallSupported: false,
22+
installReadiness: "ready" as const,
23+
manualGuideKeys: [],
24+
docUrls: {
25+
provider: "",
26+
prerequisites: {},
27+
},
28+
},
29+
loading: false,
30+
};
31+
}
32+
33+
describe("DraftLauncher", () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
mockUseProviderLauncher.mockReturnValue({
37+
states: {
38+
claude: createRuntimeState("claude"),
39+
codex: createRuntimeState("codex"),
40+
},
41+
launch: vi.fn(),
42+
});
43+
});
44+
45+
it("uses shared IconButton compatibility classes for header actions", () => {
46+
const store = createStore();
47+
const onClosePane = vi.fn();
48+
const onSplitPane = vi.fn();
49+
50+
store.set(localeAtom, "en");
51+
store.set(wsClientAtom, {
52+
sendCommand: vi.fn(),
53+
subscribe: vi.fn(() => () => {}),
54+
} as never);
55+
56+
render(
57+
<Provider store={store}>
58+
<DraftLauncher
59+
workspaceId="ws-123"
60+
paneId="pane-1"
61+
onClosePane={onClosePane}
62+
onSplitPane={onSplitPane}
63+
/>
64+
</Provider>
65+
);
66+
67+
const splitHorizontal = screen.getByRole("button", { name: "Split horizontal" });
68+
const splitVertical = screen.getByRole("button", { name: "Split vertical" });
69+
const close = screen.getByRole("button", { name: "Close" });
70+
71+
expect(splitHorizontal).toHaveClass("btn", "btn-ghost", "btn-sm", "session-action-btn");
72+
expect(splitVertical).toHaveClass("btn", "btn-ghost", "btn-sm", "session-action-btn");
73+
expect(close).toHaveClass(
74+
"btn",
75+
"btn-ghost",
76+
"btn-sm",
77+
"session-action-btn",
78+
"session-action-btn-close"
79+
);
80+
81+
fireEvent.click(splitHorizontal);
82+
fireEvent.click(splitVertical);
83+
fireEvent.click(close);
84+
85+
expect(onSplitPane).toHaveBeenNthCalledWith(1, "pane-1", "horizontal");
86+
expect(onSplitPane).toHaveBeenNthCalledWith(2, "pane-1", "vertical");
87+
expect(onClosePane).toHaveBeenCalledWith("pane-1");
88+
});
89+
});

packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ArrowRight, Bot, FlipHorizontal, FlipVertical, Sparkles, X } from "luci
44
import type { FC } from "react";
55
import { dispatchCommandAtom } from "../../../../atoms/connection";
66
import { sessionsAtom } from "../../../../atoms/sessions";
7-
import { Button, StatusDot, Tag, Tooltip } from "../../../../components/ui";
7+
import { Button, IconButton, StatusDot, Tag, Tooltip } from "../../../../components/ui";
88
import { useTranslation } from "../../../../lib/i18n";
99
import { type ProviderId, useProviderLauncher } from "../../actions/use-provider-launcher";
1010

@@ -132,31 +132,31 @@ export const DraftLauncher: FC<DraftLauncherProps> = ({
132132

133133
<div className="session-header-actions">
134134
<Tooltip content="Split horizontal">
135-
<button
135+
<IconButton
136+
aria-label="Split horizontal"
136137
className="session-action-btn"
138+
icon={<FlipHorizontal size={13} />}
137139
onClick={handleSplitHorizontal}
138-
aria-label="Split horizontal"
139-
>
140-
<FlipHorizontal size={13} />
141-
</button>
140+
size="sm"
141+
/>
142142
</Tooltip>
143143
<Tooltip content="Split vertical">
144-
<button
144+
<IconButton
145+
aria-label="Split vertical"
145146
className="session-action-btn"
147+
icon={<FlipVertical size={13} />}
146148
onClick={handleSplitVertical}
147-
aria-label="Split vertical"
148-
>
149-
<FlipVertical size={13} />
150-
</button>
149+
size="sm"
150+
/>
151151
</Tooltip>
152152
<Tooltip content="Close">
153-
<button
153+
<IconButton
154+
aria-label="Close"
154155
className="session-action-btn session-action-btn-close"
156+
icon={<X size={14} />}
155157
onClick={handleClosePane}
156-
aria-label="Close"
157-
>
158-
<X size={14} />
159-
</button>
158+
size="sm"
159+
/>
160160
</Tooltip>
161161
</div>
162162
</div>

packages/web/src/features/agent-panes/views/shared/session-card.tsx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useEffect, useRef, useState } from "react";
1313
import { pendingFocusSessionAtom } from "../../../../atoms/app-ui";
1414
import { sessionByIdAtomFamily } from "../../../../atoms/sessions";
1515
import { workspaceByIdAtomFamily } from "../../../../atoms/workspaces";
16-
import { ProgressBar, StatusDot, Tag, Tooltip } from "../../../../components/ui";
16+
import { IconButton, ProgressBar, StatusDot, Tag, Tooltip } from "../../../../components/ui";
1717
import { useSupervisor } from "../../../supervisor/actions/use-supervisor";
1818
import { ObjectiveDialog } from "../../../supervisor/views/shared/objective-dialog";
1919
import { SupervisorCard } from "../../../supervisor/views/shared/supervisor-card";
@@ -154,41 +154,41 @@ export const SessionCard: FC<SessionCardProps> = ({
154154
<div className="session-header-actions">
155155
{session.state === "running" ? (
156156
<Tooltip content="Stop">
157-
<button
157+
<IconButton
158+
aria-label="Stop"
158159
className="session-action-btn"
160+
icon={<Square size={13} />}
159161
onClick={() => void onStop?.()}
160-
aria-label="Stop"
161-
>
162-
<Square size={13} />
163-
</button>
162+
size="sm"
163+
/>
164164
</Tooltip>
165165
) : null}
166166
<Tooltip content="Split horizontal">
167-
<button
167+
<IconButton
168+
aria-label="Split horizontal"
168169
className="session-action-btn"
170+
icon={<FlipHorizontal size={13} />}
169171
onClick={() => onSplitHorizontal?.()}
170-
aria-label="Split horizontal"
171-
>
172-
<FlipHorizontal size={13} />
173-
</button>
172+
size="sm"
173+
/>
174174
</Tooltip>
175175
<Tooltip content="Split vertical">
176-
<button
176+
<IconButton
177+
aria-label="Split vertical"
177178
className="session-action-btn"
179+
icon={<FlipVertical size={13} />}
178180
onClick={() => onSplitVertical?.()}
179-
aria-label="Split vertical"
180-
>
181-
<FlipVertical size={13} />
182-
</button>
181+
size="sm"
182+
/>
183183
</Tooltip>
184184
<Tooltip content="Close">
185-
<button
185+
<IconButton
186+
aria-label="Close"
186187
className="session-action-btn session-action-btn-close"
188+
icon={<X size={14} />}
187189
onClick={() => void onClose?.()}
188-
aria-label="Close"
189-
>
190-
<X size={14} />
191-
</button>
190+
size="sm"
191+
/>
192192
</Tooltip>
193193
</div>
194194
) : null}

packages/web/src/features/mobile-select/components/mobile-select-sheet.test.tsx

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,15 +287,72 @@ describe("MobileSelectSheet", () => {
287287

288288
expect(row).not.toBeNull();
289289

290-
await user.click(
291-
within(row as HTMLElement).getByRole("button", { name: "Close Current Session" })
290+
const trailingAction = within(row as HTMLElement).getByRole("button", {
291+
name: "Close Current Session",
292+
});
293+
294+
expect(trailingAction).toHaveClass(
295+
"btn",
296+
"btn-ghost",
297+
"btn-lg",
298+
"mobile-select-sheet__item-side-action"
292299
);
293300

301+
await user.click(trailingAction);
302+
294303
expect(onCloseSession).toHaveBeenCalledTimes(1);
295304
expect(onSelect).not.toHaveBeenCalled();
296305
expect(onClose).not.toHaveBeenCalled();
297306
});
298307

308+
it("preserves danger tone and disabled behavior on trailing icon actions", async () => {
309+
const user = userEvent.setup();
310+
const onAction = vi.fn();
311+
312+
renderWithEnglishLocale(
313+
<MobileSelectSheet
314+
title="Agent Sessions"
315+
sections={[
316+
{
317+
kind: "options",
318+
id: "sessions",
319+
items: [
320+
{
321+
id: "sess_2",
322+
label: "Codex",
323+
trailingAction: {
324+
id: "close-current",
325+
ariaLabel: "Close Current Session",
326+
disabled: true,
327+
icon: <span aria-hidden="true">x</span>,
328+
onAction,
329+
tone: "danger",
330+
},
331+
},
332+
],
333+
},
334+
]}
335+
onSelect={vi.fn()}
336+
onClose={vi.fn()}
337+
/>
338+
);
339+
340+
const trailingAction = screen.getByRole("button", { name: "Close Current Session" });
341+
342+
expect(trailingAction).toHaveClass(
343+
"btn",
344+
"btn-ghost",
345+
"btn-lg",
346+
"mobile-select-sheet__item-side-action",
347+
"mobile-select-sheet__item-side-action--danger"
348+
);
349+
expect(trailingAction).toBeDisabled();
350+
351+
await user.click(trailingAction);
352+
353+
expect(onAction).not.toHaveBeenCalled();
354+
});
355+
299356
it("keeps selected state on the row background and does not render a check icon", () => {
300357
renderWithEnglishLocale(
301358
<MobileSelectSheet

packages/web/src/features/mobile-select/components/mobile-select-sheet.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type ReactNode, useId, useMemo, useState } from "react";
2+
import { IconButton } from "../../../components/ui";
23
import { Sheet } from "../../../components/ui/sheet";
34
import { Tag } from "../../../components/ui/tag";
45
import { useTranslation } from "../../../lib/i18n";
@@ -291,24 +292,23 @@ export function MobileSelectSheet({
291292
data-selected={isSelected ? "true" : "false"}
292293
>
293294
{optionButton}
294-
<button
295-
type="button"
295+
<IconButton
296+
aria-label={item.trailingAction.ariaLabel}
296297
className={`mobile-select-sheet__item-side-action ${
297298
item.trailingAction.tone === "danger"
298299
? "mobile-select-sheet__item-side-action--danger"
299300
: ""
300301
}`}
301-
aria-label={item.trailingAction.ariaLabel}
302302
disabled={item.disabled || item.trailingAction.disabled}
303+
icon={
304+
<span className="mobile-select-sheet__item-side-action-icon">
305+
{item.trailingAction.icon}
306+
</span>
307+
}
303308
onClick={() => handleTrailingAction(item.trailingAction.onAction)}
304-
>
305-
<span
306-
className="mobile-select-sheet__item-side-action-icon"
307-
aria-hidden="true"
308-
>
309-
{item.trailingAction.icon}
310-
</span>
311-
</button>
309+
size="lg"
310+
variant="ghost"
311+
/>
312312
</div>
313313
);
314314
})

0 commit comments

Comments
 (0)