Skip to content

Commit 1ed2b66

Browse files
committed
feat(web): finish icon button feature migrations
1 parent 04ad0e9 commit 1ed2b66

13 files changed

Lines changed: 260 additions & 48 deletions

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, 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 |
6+
| IconButton | 🟡 partial | `.btn` icon-only | bounded modal/dialog and toast close, desktop/mobile topbar icon-only triggers, workspace fullscreen, supervisor card actions, git/file-tree/status row actions, code-editor and git-diff close chrome, mobile-select trailing side actions, agent session header actions, selected terminal/workspace flows, shortcut reset, mobile workspace drawer close, 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, 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.
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 git-status-bar fetch action, the code-editor mobile toggle plus editor/diff close controls, the shared toast dismiss control, the desktop workspace-launch close control, the mobile workspace-drawer close action, 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`, `git-status-bar__item*`, `code-mode-btn`, `mobile-sheet__action--icon`, `toast__close`, `launch-close-btn`, `mobile-workspace-drawer__item-close`, `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/components/ui/toast/index.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe("Toast", () => {
4949
);
5050

5151
const closeButton = screen.getByRole("button", { name: "Close notification" });
52-
expect(closeButton).toHaveClass("toast__close");
52+
expect(closeButton).toHaveClass("btn", "btn-ghost", "btn-sm", "toast__close");
5353

5454
await user.click(closeButton);
5555

packages/web/src/components/ui/toast/index.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import clsx from "clsx";
22
import { X } from "lucide-react";
33
import type { HTMLAttributes, ReactNode } from "react";
4+
import { IconButton } from "../icon-button";
45
import styles from "./index.module.css";
56

67
export type ToastTone = "success" | "error" | "warning" | "info";
@@ -115,17 +116,16 @@ export function Toast({
115116
</button>
116117
) : null}
117118

118-
<button
119+
<IconButton
119120
aria-label={closeLabel}
120121
className={clsx(styles.close, "toast__close")}
122+
icon={<X size={14} />}
121123
onClick={(event) => {
122124
event.stopPropagation();
123125
onDismiss();
124126
}}
125-
type="button"
126-
>
127-
<X size={14} />
128-
</button>
127+
size="sm"
128+
/>
129129
</div>
130130
);
131131
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { CodeEditorHeaderActions, type CodeEditorState } from "./code-editor-host";
4+
5+
vi.mock("../../../../lib/i18n", () => ({
6+
useTranslation: () => (key: string) => {
7+
switch (key) {
8+
case "code_editor.edit_as_text":
9+
return "Edit as text";
10+
case "code_editor.preview_as_image":
11+
return "Preview as image";
12+
case "code_editor.mode_text":
13+
return "Text";
14+
case "code_editor.mode_image":
15+
return "Image";
16+
case "code_editor.saving":
17+
return "Saving";
18+
case "action.save_file":
19+
return "Save File";
20+
case "action.close":
21+
return "Close";
22+
default:
23+
return key;
24+
}
25+
},
26+
}));
27+
28+
function createState(overrides: Partial<CodeEditorState> = {}): CodeEditorState {
29+
return {
30+
activeFilePath: null,
31+
activeExternalStatus: null,
32+
activeLoadError: null,
33+
canSave: true,
34+
currentFile: undefined,
35+
handleClose: vi.fn(),
36+
handleContentChange: vi.fn(),
37+
handleSave: vi.fn(),
38+
isImageFile: false,
39+
isSaving: false,
40+
isSvgTextBacked: true,
41+
isTextFile: true,
42+
openInDiffMode: vi.fn(),
43+
saveError: null,
44+
toggleSvgTextMode: vi.fn(),
45+
workspace: undefined,
46+
workspaceId: undefined,
47+
...overrides,
48+
};
49+
}
50+
51+
describe("CodeEditorHeaderActions", () => {
52+
it("uses shared IconButton compatibility classes for the mobile icon toggle", () => {
53+
const state = createState();
54+
55+
render(<CodeEditorHeaderActions state={state} variant="mobile" />);
56+
57+
const toggleButton = screen.getByRole("button", { name: "Preview as image" });
58+
59+
expect(toggleButton).toHaveClass(
60+
"btn",
61+
"btn-ghost",
62+
"mobile-sheet__action",
63+
"mobile-sheet__action--icon"
64+
);
65+
66+
fireEvent.click(toggleButton);
67+
expect(state.toggleSvgTextMode).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it("uses shared IconButton compatibility classes for the desktop close action", () => {
71+
const state = createState();
72+
73+
render(<CodeEditorHeaderActions state={state} />);
74+
75+
const closeButton = screen.getByRole("button", { name: "Close" });
76+
77+
expect(closeButton).toHaveClass("btn", "btn-ghost", "btn-sm", "code-mode-btn");
78+
79+
fireEvent.click(closeButton);
80+
expect(state.handleClose).toHaveBeenCalledTimes(1);
81+
});
82+
});

packages/web/src/features/code-editor/views/shared/code-editor-host.tsx

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AlertCircle, FileText, Image as ImageIcon, Save, X } from "lucide-react";
22
import type { FC } from "react";
3-
import { EmptyState, Tooltip } from "../../../../components/ui";
3+
import { EmptyState, IconButton, Tooltip } from "../../../../components/ui";
44
import { useTranslation } from "../../../../lib/i18n";
55
import { useCodeEditorActions } from "../../actions/use-code-editor-actions";
66
import { ImagePreview } from "../../components/image-preview";
@@ -50,14 +50,12 @@ export const CodeEditorHeaderActions: FC<CodeEditorHeaderActionsProps> = ({
5050
<div className="mobile-sheet__header-actions">
5151
{isSvgTextBacked ? (
5252
<Tooltip content={toggleModeTitle}>
53-
<button
54-
type="button"
53+
<IconButton
54+
aria-label={toggleModeTitle}
5555
className="mobile-sheet__action mobile-sheet__action--icon"
56+
icon={isImageFile ? <FileText size={16} /> : <ImageIcon size={16} />}
5657
onClick={toggleSvgTextMode}
57-
aria-label={toggleModeTitle}
58-
>
59-
{isImageFile ? <FileText size={16} /> : <ImageIcon size={16} />}
60-
</button>
58+
/>
6159
</Tooltip>
6260
) : null}
6361
<button
@@ -101,14 +99,13 @@ export const CodeEditorHeaderActions: FC<CodeEditorHeaderActionsProps> = ({
10199
</button>
102100
</Tooltip>
103101
<Tooltip content={t("action.close")}>
104-
<button
105-
type="button"
102+
<IconButton
103+
aria-label={t("action.close")}
106104
className="code-mode-btn"
105+
icon={<X size={12} />}
107106
onClick={handleClose}
108-
aria-label={t("action.close")}
109-
>
110-
<X size={12} />
111-
</button>
107+
size="sm"
108+
/>
112109
</Tooltip>
113110
</div>
114111
);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { createStore, Provider } from "jotai";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { MobileWorkspaceDrawer } from "./mobile-workspace-drawer";
6+
7+
const navigateMock = vi.fn();
8+
const closeWorkspaceMock = vi.fn();
9+
10+
vi.mock("react-router-dom", async () => {
11+
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
12+
return {
13+
...actual,
14+
useNavigate: () => navigateMock,
15+
};
16+
});
17+
18+
vi.mock("../../actions/use-workspace-close-action", () => ({
19+
useWorkspaceCloseAction: () => closeWorkspaceMock,
20+
}));
21+
22+
vi.mock("../../../../lib/i18n", () => ({
23+
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
24+
switch (key) {
25+
case "mobile.workspace_drawer.close":
26+
return "Close workspace drawer";
27+
case "mobile.workspace_drawer.aria_label":
28+
return "Workspace drawer";
29+
case "label.workspace":
30+
return "Workspace";
31+
case "mobile.workspace_drawer.select_title":
32+
return "Select Workspace";
33+
case "mobile.workspace_drawer.switch_to_workspace":
34+
return `Switch to ${params?.name ?? ""}`;
35+
case "mobile.workspace_drawer.close_workspace":
36+
return `Close ${params?.name ?? ""}`;
37+
case "tooltip.new_workspace":
38+
return "New Workspace";
39+
default:
40+
return key;
41+
}
42+
},
43+
}));
44+
45+
describe("MobileWorkspaceDrawer", () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks();
48+
closeWorkspaceMock.mockResolvedValue(true);
49+
});
50+
51+
it("uses shared IconButton compatibility classes for workspace close actions", async () => {
52+
const user = userEvent.setup();
53+
const onClose = vi.fn();
54+
const store = createStore();
55+
56+
render(
57+
<Provider store={store}>
58+
<MobileWorkspaceDrawer
59+
activeWorkspaceId="ws-1"
60+
isOpen
61+
onClose={onClose}
62+
onOpenWorkspaceLauncher={vi.fn()}
63+
workspaces={[
64+
{
65+
id: "ws-1",
66+
path: "/tmp/demo",
67+
targetRuntime: "native",
68+
openedAt: 1,
69+
lastActiveAt: 1,
70+
uiState: {
71+
leftPanelWidth: 320,
72+
bottomPanelHeight: 240,
73+
focusMode: false,
74+
},
75+
},
76+
]}
77+
/>
78+
</Provider>
79+
);
80+
81+
const closeButton = screen.getByRole("button", { name: "Close demo" });
82+
83+
expect(closeButton).toHaveClass(
84+
"btn",
85+
"btn-ghost",
86+
"btn-lg",
87+
"mobile-workspace-drawer__item-close"
88+
);
89+
90+
await user.click(closeButton);
91+
92+
expect(closeWorkspaceMock).toHaveBeenCalledWith("ws-1", { navigateHomeWhenEmpty: true });
93+
await waitFor(() => {
94+
expect(onClose).toHaveBeenCalledTimes(1);
95+
});
96+
});
97+
});

packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useSetAtom } from "jotai";
33
import { X } from "lucide-react";
44
import { useNavigate } from "react-router-dom";
55
import { activeWorkspaceIdAtom } from "../../../../atoms/workspaces";
6+
import { IconButton } from "../../../../components/ui";
67
import { useTranslation } from "../../../../lib/i18n";
78
import { formatWorkspaceLabel } from "../../../notifications/format";
89
import { useWorkspaceCloseAction } from "../../actions/use-workspace-close-action";
@@ -76,10 +77,10 @@ export function MobileWorkspaceDrawer({
7677
<span className="mobile-workspace-drawer__item-name">{displayName}</span>
7778
<span className="mobile-workspace-drawer__item-path">{workspace.path}</span>
7879
</button>
79-
<button
80-
type="button"
81-
className="mobile-workspace-drawer__item-close"
80+
<IconButton
8281
aria-label={t("mobile.workspace_drawer.close_workspace", { name: displayName })}
82+
className="mobile-workspace-drawer__item-close"
83+
icon={<X size={16} />}
8384
onClick={() => {
8485
void closeWorkspace(workspace.id, { navigateHomeWhenEmpty: true }).then(
8586
(closed) => {
@@ -89,9 +90,8 @@ export function MobileWorkspaceDrawer({
8990
}
9091
);
9192
}}
92-
>
93-
<X size={16} />
94-
</button>
93+
size="lg"
94+
/>
9595
</div>
9696
);
9797
})}

packages/web/src/features/workspace/views/shared/git-diff-viewer.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe("GitDiffViewer", () => {
7272
);
7373

7474
const closeButton = screen.getByRole("button", { name: /close|/i });
75+
expect(closeButton).toHaveClass("btn", "btn-ghost", "btn-sm", "code-mode-btn");
7576
expect(closeButton).not.toHaveAttribute("title");
7677

7778
fireEvent.mouseEnter(closeButton);

packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { X } from "lucide-react";
22
import type { FC } from "react";
33
import { useMemo } from "react";
4-
import { EmptyState, Tooltip } from "../../../../components/ui";
4+
import { EmptyState, IconButton, Tooltip } from "../../../../components/ui";
55
import { useTranslation } from "../../../../lib/i18n";
66
import { useGitDiffViewerActions } from "../../actions/use-git-actions";
77

@@ -72,14 +72,13 @@ export const GitDiffViewer: FC<GitDiffViewerProps> = ({
7272
{preview && showCloseButton ? (
7373
<div className="code-mode-toggle">
7474
<Tooltip content={t("action.close")}>
75-
<button
76-
type="button"
75+
<IconButton
76+
aria-label={t("action.close")}
7777
className="code-mode-btn"
78+
icon={<X size={12} />}
7879
onClick={handleClose}
79-
aria-label={t("action.close")}
80-
>
81-
<X size={12} />
82-
</button>
80+
size="sm"
81+
/>
8382
</Tooltip>
8483
</div>
8584
) : null}

packages/web/src/features/workspace/views/shared/git-status-bar.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ describe("GitStatusBar", () => {
5050
const toolbar = screen.getByRole("button", { name: "Fetch" }).closest(".git-status-bar");
5151
expect(toolbar).not.toBeNull();
5252

53+
expect(screen.getByRole("button", { name: "Fetch" })).toHaveClass(
54+
"btn",
55+
"btn-ghost",
56+
"btn-sm",
57+
"git-status-bar__item",
58+
"git-status-bar__item--actionable"
59+
);
60+
5361
const buttons = within(toolbar as HTMLElement).getAllByRole("button");
5462
expect(buttons.at(-1)).toHaveAccessibleName("Fetch");
5563
});

0 commit comments

Comments
 (0)