Skip to content

Commit 67682d1

Browse files
committed
feat(web): expand empty state migration coverage
1 parent a9c07b4 commit 67682d1

23 files changed

Lines changed: 656 additions & 68 deletions

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
| Tooltip | 🟡 partial | native `title` hover labels | branch picker, code-editor actions, file-tree actions, git-diff close, fullscreen, topbar actions, connection-status, git status-strip branch, session/supervisor actions, selected settings actions, and workspace/terminal file-toolbar actions covered; truncation/path and other non-action long-text titles remain deferred | 2026-05-09 |
2020
| ProgressBar | 🟢 complete | `--progress-height` patterns | 0 | 2026-05-09 |
2121
| Notice | 🟢 complete | `.settings-page__notice*` | 0 | 2026-05-09 |
22-
| EmptyState | 🟡 partial | feature-specific empty state blocks | centered shared shells plus workspace desktop/mobile no-session or no-workspace empties and workspace route resolving/load-failed shells covered; list-style and other feature-owned empty blocks remain | 2026-05-09 |
22+
| EmptyState | 🟡 partial | feature-specific empty state blocks | centered shared shells plus workspace desktop/mobile no-session or no-workspace empties, agent-panes no-workspace, topbar no-workspace, workspace route resolving/load-failed shells, worktree detail no-diff and empty-tree states, worktree manager list-empty, workspace launch directory-empty, command palette no-results, desktop branch quick-pick loading/idle/filter-empty states, shared git-panel compact worktree/change/history shells, file-tree-panel loading/search-empty/tree fallback shells, the mobile select empty shell, and the mobile supervisor enable shell covered; additional list-style and other feature-owned empty blocks remain | 2026-05-09 |
2323
| Tabs | 🟢 complete | `.panel-tabs`, `.panel-tab`, `.worktree-tabs`, `.worktree-tab`, feature-local workspace/terminal tab shells | 0 | 2026-05-09 |
2424
| SegmentedControl | 🟢 complete | `.settings-provider-tabs`, `.settings-provider-tab`, `.settings-provider-subnav`, `.settings-provider-subnav-button`, `.shortcuts-category-tabs`, `.shortcuts-category-tab` | 0 | 2026-05-09 |
2525
| Sheet | 🟢 complete | `.mobile-sheet*` | 0 | 2026-05-09 |
@@ -71,6 +71,6 @@
7171

7272
`SegmentedControl` now completes the bounded selector-family inventory: provider chooser tabs, provider sub-navigation, and shortcuts category selectors all use the shared primitive from the public UI barrel while preserving the existing legacy compatibility classes for zero-regression styling.
7373

74-
`EmptyState` now covers a broader bounded shell slice: config editor, terminal panel, git diff/code editor/image preview empties, the desktop workspace no-workspace shell, the mobile agent empty shell, and the workspace route resolving/load-failed shells all use the shared primitive from the public UI barrel while preserving feature-owned card chrome such as `workspace-resolving-shell` and `workspace-resolving-card`. List-style and other feature-owned empty-state chromes remain intentionally deferred.
74+
`EmptyState` now covers a broader bounded shell slice: config editor, terminal panel, git diff/code editor/image preview empties, the desktop workspace no-workspace shell, the agent-panes no-workspace shell, the topbar no-workspace hint, the worktree detail no-diff and empty-tree states, the worktree manager list-empty state, the workspace launch directory-empty state, the command palette no-results state, the desktop branch quick-pick loading and compact empty-result states, the shared git-panel compact worktree/change/history shells, the shared file-tree-panel loading/search-empty/tree fallback shells, the mobile agent empty shell, the mobile select empty shell, the mobile supervisor enable shell, and the workspace route resolving/load-failed shells all use the shared primitive from the public UI barrel while preserving feature-owned chrome such as `workspace-resolving-shell`, `workspace-resolving-card`, `agent-panes-empty`, `topbar-empty-state`, `mobile-select-sheet__empty`, `mobile-supervisor-sheet__empty`, `worktree-empty`, `directory-empty`, `command-palette-empty`, `branch-quick-pick-empty`, `git-panel-empty`, and `file-tree-empty`. Additional list-style and other feature-owned empty-state chromes remain intentionally deferred.
7575

7676
`Tabs` now complete the bounded tab-navigation inventory: workspace desktop/mobile/worktree surfaces, the topbar workspace switcher, and the desktop terminal session tabs all use the shared primitive from the public UI barrel while preserving legacy compatibility classes and feature-owned closable-tab shells where secondary close actions must remain siblings of the tab trigger for valid DOM and keyboard semantics.

packages/web/src/features/agent-panes/index.test.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { localeAtom } from "../../atoms/app-ui";
55
import { connectionStatusAtom, wsClientAtom } from "../../atoms/connection";
66
import { sessionsAtom } from "../../atoms/sessions";
7-
import { activeWorkspaceIdAtom } from "../../atoms/workspaces";
7+
import { activeWorkspaceIdAtom, workspacesLoadStateAtom } from "../../atoms/workspaces";
88
import { seedReadyWorkspaceState } from "../../test-utils/workspace-state";
99
import { LEGACY_PANE_LAYOUT_STORAGE_KEY_PREFIX, paneLayoutAtomFamily } from "./atoms/pane-layout";
1010
import { AgentPanes } from "./index";
@@ -153,6 +153,26 @@ describe("AgentPanes", () => {
153153
window.localStorage.clear();
154154
});
155155

156+
it("renders the shared empty state when no workspace is active", () => {
157+
const store = createStore();
158+
store.set(localeAtom, "en");
159+
store.set(connectionStatusAtom, "connected");
160+
store.set(workspacesLoadStateAtom, "ready");
161+
store.set(activeWorkspaceIdAtom, null);
162+
store.set(wsClientAtom, {
163+
sendCommand: vi.fn(),
164+
subscribe: vi.fn(() => () => {}),
165+
} as never);
166+
167+
render(
168+
<Provider store={store}>
169+
<AgentPanes />
170+
</Provider>
171+
);
172+
173+
expect(screen.getByText("No workspace open")).toBeInTheDocument();
174+
});
175+
156176
it("splits the active session pane when session-card requests a split", async () => {
157177
const { store } = createAgentPaneStore();
158178

packages/web/src/features/agent-panes/index.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { useAtomValue } from "jotai";
99
import type { FC } from "react";
1010
import { activeWorkspaceAtom } from "../../atoms/workspaces";
11+
import { EmptyState } from "../../components/ui";
1112
import { useTranslation } from "../../lib/i18n";
1213
import { usePaneActions } from "./actions/use-pane-actions";
1314
import { useSessionActions } from "./actions/use-session-actions";
@@ -31,6 +32,12 @@ interface AgentPanesProps {
3132
hydrateSessions?: boolean;
3233
}
3334

35+
const emptyStateTitleStyle = {
36+
margin: 0,
37+
color: "var(--text-tertiary)",
38+
fontWeight: "var(--font-normal)",
39+
};
40+
3441
export const AgentPanes: FC<AgentPanesProps> = ({ hydrateSessions = true }) => {
3542
const t = useTranslation();
3643
const workspace = useAtomValue(activeWorkspaceAtom);
@@ -48,7 +55,10 @@ export const AgentPanes: FC<AgentPanesProps> = ({ hydrateSessions = true }) => {
4855
if (!workspace) {
4956
return (
5057
<div className="agent-panes-empty">
51-
<p>{t("workspace.no_workspace")}</p>
58+
<EmptyState
59+
style={{ padding: 0 }}
60+
title={<p style={emptyStateTitleStyle}>{t("workspace.no_workspace")}</p>}
61+
/>
5262
</div>
5363
);
5464
}

packages/web/src/features/command-palette/components/command-palette.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,32 @@ describe("CommandPalette", () => {
148148
).toBeInTheDocument();
149149
});
150150

151+
it("renders the shared empty state shell for no command results", () => {
152+
const store = createStore();
153+
store.set(localeAtom, "en");
154+
store.set(commandPaletteOpenAtom, true);
155+
store.set(workspacesAtom, {
156+
"ws-1": createWorkspace("ws-1", "/tmp/one"),
157+
});
158+
store.set(workspaceOrderAtom, ["ws-1"]);
159+
store.set(workspacesLoadStateAtom, "ready");
160+
161+
render(
162+
<Provider store={store}>
163+
<CommandPalette />
164+
</Provider>
165+
);
166+
167+
fireEvent.change(screen.getByRole("textbox"), {
168+
target: { value: "__missing-command__" },
169+
});
170+
171+
const emptyStateTitle = screen.getByText("No results found");
172+
173+
expect(emptyStateTitle.tagName).toBe("P");
174+
expect(emptyStateTitle.closest(".command-palette-empty")).toBeTruthy();
175+
});
176+
151177
it("keeps desktop-only layout commands available on desktop and executes them", () => {
152178
const store = createStore();
153179
store.set(localeAtom, "en");

packages/web/src/features/command-palette/components/command-palette.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
orderedWorkspacesAtom,
1616
resolvedActiveWorkspaceIdAtom,
1717
} from "../../../atoms/workspaces";
18-
import { Sheet } from "../../../components/ui";
18+
import { EmptyState, Sheet } from "../../../components/ui";
1919
import { useViewport } from "../../../hooks/use-viewport";
2020
import { useTranslation } from "../../../lib/i18n";
2121
import { formatWorkspaceLabel } from "../../notifications/format";
@@ -37,6 +37,18 @@ interface Command {
3737

3838
type ShellKind = "desktop" | "mobile";
3939

40+
const commandPaletteEmptyStateStyle = {
41+
minHeight: "auto",
42+
padding: "var(--sp-8)",
43+
gap: 0,
44+
};
45+
46+
const commandPaletteEmptyStateTitleStyle = {
47+
margin: 0,
48+
color: "var(--text-tertiary)",
49+
fontWeight: "var(--font-normal)",
50+
};
51+
4052
/**
4153
* Command Palette
4254
*
@@ -196,7 +208,11 @@ export function CommandPalette() {
196208
</div>
197209
))
198210
) : (
199-
<div className="command-palette-empty">{t("command.no_results")}</div>
211+
<EmptyState
212+
className="command-palette-empty"
213+
style={commandPaletteEmptyStateStyle}
214+
title={<p style={commandPaletteEmptyStateTitleStyle}>{t("command.no_results")}</p>}
215+
/>
200216
)}
201217
</div>
202218
);

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,33 @@ describe("MobileSelectSheet", () => {
464464
expect(onCreate).toHaveBeenCalledWith("feature/mobile-select");
465465
});
466466

467+
it("renders the empty state through the shared primitive while preserving the feature shell hook", () => {
468+
renderWithEnglishLocale(
469+
<MobileSelectSheet
470+
title="Branch"
471+
sections={[{ kind: "options", id: "branches", items: [] }]}
472+
emptyText="No branches found"
473+
onSelect={vi.fn()}
474+
onClose={vi.fn()}
475+
/>
476+
);
477+
478+
const emptyState = document.querySelector(".mobile-select-sheet__empty");
479+
const emptyStateStyle = emptyState?.getAttribute("style") ?? "";
480+
const emptyStateTitleStyle = emptyState?.firstElementChild?.getAttribute("style") ?? "";
481+
482+
expect(emptyState).not.toBeNull();
483+
expect(emptyState).toHaveTextContent("No branches found");
484+
expect(emptyStateStyle).toContain("min-height: auto");
485+
expect(emptyStateStyle).toContain("padding: var(--sp-6) var(--sp-4)");
486+
expect(emptyStateStyle).toContain("gap: 0");
487+
expect(emptyState?.childElementCount).toBe(1);
488+
expect(emptyState?.firstElementChild).toHaveTextContent("No branches found");
489+
expect(emptyState?.firstElementChild?.tagName).toBe("DIV");
490+
expect(emptyStateTitleStyle).toContain("color: var(--text-tertiary)");
491+
expect(emptyStateTitleStyle).toContain("font-weight: var(--font-normal)");
492+
});
493+
467494
it("keeps the sheet open when closeOnSelect is false", async () => {
468495
const user = userEvent.setup();
469496
const onClose = vi.fn();

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type ReactNode, useId, useMemo, useState } from "react";
2-
import { IconButton } from "../../../components/ui";
2+
import { EmptyState, IconButton } from "../../../components/ui";
33
import { Sheet } from "../../../components/ui/sheet";
44
import { Tag } from "../../../components/ui/tag";
55
import { useTranslation } from "../../../lib/i18n";
@@ -80,6 +80,19 @@ export interface MobileSelectSheetProps {
8080
onClose: () => void;
8181
}
8282

83+
const mobileSelectEmptyStateStyle = {
84+
minHeight: "auto",
85+
padding: "var(--sp-6) var(--sp-4)",
86+
gap: 0,
87+
};
88+
89+
const mobileSelectEmptyStateTitleStyle = {
90+
color: "var(--text-tertiary)",
91+
fontSize: "inherit",
92+
fontWeight: "var(--font-normal)",
93+
lineHeight: "inherit",
94+
};
95+
8396
export function MobileSelectSheet({
8497
title,
8598
sections,
@@ -383,7 +396,11 @@ export function MobileSelectSheet({
383396
) : null}
384397

385398
{!hasVisibleItems && !canCreate ? (
386-
<div className="mobile-select-sheet__empty">{resolvedEmptyText}</div>
399+
<EmptyState
400+
className="mobile-select-sheet__empty"
401+
style={mobileSelectEmptyStateStyle}
402+
title={<div style={mobileSelectEmptyStateTitleStyle}>{resolvedEmptyText}</div>}
403+
/>
387404
) : null}
388405
</>
389406
)}

packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,13 @@ describe("MobileSupervisorSheet", () => {
9494
</Provider>
9595
);
9696

97-
expect(screen.getByText("Supervisor is not enabled")).toBeInTheDocument();
97+
const emptyState = screen
98+
.getByText("Supervisor is not enabled")
99+
.closest(".mobile-supervisor-sheet__empty");
100+
101+
expect(emptyState).not.toBeNull();
102+
expect(emptyState).toHaveTextContent("Supervisor");
103+
expect(emptyState).toHaveTextContent("Supervisor is not enabled");
98104

99105
fireEvent.click(screen.getByRole("button", { name: "Enable Objective" }));
100106

packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useSetAtom } from "jotai";
22
import { useEffect, useState } from "react";
3-
import { Button, Sheet } from "../../../../components/ui";
3+
import { Button, EmptyState, Sheet } from "../../../../components/ui";
44
import { useTranslation } from "../../../../lib/i18n";
55
import {
66
type ObjectiveDialogEvaluatorProviderId,
@@ -20,6 +20,23 @@ interface MobileSupervisorSheetProps {
2020
onClose: () => void;
2121
}
2222

23+
const mobileSupervisorEmptyStateStyle = {
24+
minHeight: 0,
25+
padding: "var(--sp-4)",
26+
gap: "var(--sp-2)",
27+
alignItems: "stretch",
28+
textAlign: "left",
29+
};
30+
31+
const mobileSupervisorEmptySlotStyle = {
32+
width: "auto",
33+
maxWidth: "none",
34+
color: "inherit",
35+
fontSize: "inherit",
36+
fontWeight: "inherit",
37+
lineHeight: "inherit",
38+
};
39+
2340
export function MobileSupervisorSheet({
2441
sessionId,
2542
workspaceId,
@@ -146,13 +163,27 @@ export function MobileSupervisorSheet({
146163
</div>
147164
</>
148165
) : (
149-
<div className="mobile-supervisor-sheet__empty">
150-
<h3>{t("supervisor.title")}</h3>
151-
<p>{t("supervisor.empty")}</p>
152-
<Button variant="primary" onClick={() => openDetail("enable")}>
153-
{t("supervisor.action.enable_objective")}
154-
</Button>
155-
</div>
166+
<EmptyState
167+
className="mobile-supervisor-sheet__empty"
168+
style={mobileSupervisorEmptyStateStyle}
169+
title={
170+
<div style={mobileSupervisorEmptySlotStyle}>
171+
<h3>{t("supervisor.title")}</h3>
172+
</div>
173+
}
174+
description={
175+
<div style={mobileSupervisorEmptySlotStyle}>
176+
<p>{t("supervisor.empty")}</p>
177+
</div>
178+
}
179+
action={
180+
<div style={{ width: "100%" }}>
181+
<Button variant="primary" onClick={() => openDetail("enable")}>
182+
{t("supervisor.action.enable_objective")}
183+
</Button>
184+
</div>
185+
}
186+
/>
156187
)}
157188
</div>
158189
}

packages/web/src/features/topbar/index.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ describe("TopBar", () => {
103103
</Provider>
104104
);
105105

106-
expect(screen.getByText("No workspace open")).toBeInTheDocument();
106+
const emptyState = screen.getByText("No workspace open").closest(".topbar-empty-state");
107+
108+
expect(emptyState).not.toBeNull();
109+
expect(emptyState).toHaveTextContent("No workspace open");
107110
expect(screen.getByRole("button", { name: "Quick Actions" })).toBeInTheDocument();
108111
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument();
109112
});

0 commit comments

Comments
 (0)