fix(web): render empty-state in sidebar when no projects configured (#1549)

* fix(web): render empty-state in sidebar when no projects configured

A fresh-install user with zero projects saw a blank sidebar and had no
way to open AddProjectModal from it. The early-return was originally
projects.length <= 1 (#381), softened to === 0 in #927, but no empty-
state UI was added at the same time.

Replace the null branch with a small ProjectSidebarEmpty sibling that
reuses the existing header (with the + button wired to AddProjectModal),
shows a one-line explainer, and renders only the ThemeToggle in the
footer (the show-killed/show-done/settings buttons are meaningless with
zero projects).

* fix(web): mark sidebar + button SVGs aria-hidden

The decorative SVG inside the labeled + buttons (empty-state and
populated sidebar) should not be announced — screen readers should rely
on the button's aria-label. Adds aria-hidden="true" to both for
consistency.

* fix(web): always mount sidebar so empty-state renders on fresh installs

Dashboard previously gated the sidebar on projects.length >= 1, leaving
ProjectSidebarEmpty unreachable. ProjectSidebar handles both cases now,
so drop the gate and add a dashboard-level test for the zero-project
path.

* fix(web): honor collapsed prop in empty sidebar branch

ProjectSidebarEmpty discarded the collapsed prop, so on a fresh install
the wrapper shrank to 44px while the inner sidebar stayed 224px and
overlapped the main content. Render a 44px-wide rail with just the +
button when collapsed, matching the populated sidebar's collapse path.
This commit is contained in:
Harsh Batheja 2026-05-04 20:13:11 +05:30 committed by GitHub
parent d0fde88f2a
commit eb06a4d090
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 191 additions and 58 deletions

View File

@ -197,13 +197,12 @@ function DashboardInner({
const showDebugBundleButton =
!isMobile &&
(process.env.NODE_ENV === "development" || debugParam === "1" || debugParam === "true");
const showSidebar = projects.length >= 1;
const { showToast } = useToast();
const [doneExpanded, setDoneExpanded] = useState(false);
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const allProjectsView = projects.length > 1 && showSidebar && projectId === undefined;
const allProjectsView = projects.length > 1 && projectId === undefined;
const currentProjectOrchestrator = useMemo(
() =>
projectId
@ -495,41 +494,39 @@ function DashboardInner({
<ConnectionBar status={connectionStatus} />
<div className="dashboard-app-shell">
<header className="dashboard-app-header">
{showSidebar ? (
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
) : null}
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
<div className="dashboard-app-header__brand">
<span className="dashboard-app-header__brand-dot" aria-hidden="true" />
<span>Agent Orchestrator</span>
@ -598,22 +595,20 @@ function DashboardInner({
<div
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
{showSidebar && (
<div
className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={projects}
sessions={sessions}
orchestrators={activeOrchestrators}
activeProjectId={projectId}
activeSessionId={activeSessionId}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileMenuOpen(false)}
/>
</div>
)}
<div
className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={projects}
sessions={sessions}
orchestrators={activeOrchestrators}
activeProjectId={projectId}
activeSessionId={activeSessionId}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileMenuOpen(false)}
/>
</div>
{mobileMenuOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileMenuOpen(false)} />
)}

View File

@ -82,11 +82,72 @@ const LEVEL_LABELS: Record<AttentionLevel, string> = {
export function ProjectSidebar(props: ProjectSidebarProps) {
if (props.projects.length === 0) {
return null;
return <ProjectSidebarEmpty collapsed={props.collapsed} />;
}
return <ProjectSidebarInner {...props} />;
}
function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) {
const [addProjectOpen, setAddProjectOpen] = useState(false);
if (collapsed) {
return (
<aside className="project-sidebar project-sidebar--collapsed flex h-full flex-col items-center gap-1 py-2">
<button
type="button"
className="project-sidebar__add-btn"
aria-label="New project"
onClick={() => setAddProjectOpen(true)}
>
<svg
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<AddProjectModal open={addProjectOpen} onClose={() => setAddProjectOpen(false)} />
</aside>
);
}
return (
<aside className="project-sidebar flex h-full flex-col">
<div className="project-sidebar__compact-hdr">
<span className="project-sidebar__sect-label">Projects</span>
<button
type="button"
className="project-sidebar__add-btn"
aria-label="New project"
onClick={() => setAddProjectOpen(true)}
>
<svg
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<div className="project-sidebar__empty flex-1 text-[var(--color-text-tertiary)]">
No projects yet. Click + to add one.
</div>
<div className="project-sidebar__footer">
<div className="flex items-center justify-end gap-1 border-t border-[var(--color-border-subtle)] px-2 py-2">
<ThemeToggle className="project-sidebar__theme-toggle" />
</div>
</div>
<AddProjectModal open={addProjectOpen} onClose={() => setAddProjectOpen(false)} />
</aside>
);
}
function ProjectSidebarInner({
projects,
sessions,
@ -373,7 +434,13 @@ function ProjectSidebarInner({
aria-label="New project"
onClick={() => setAddProjectOpen(true)}
>
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<svg
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>

View File

@ -8,6 +8,10 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }),
}));
let currentMuxLastError: string | null = null;
vi.mock("@/providers/MuxProvider", () => ({
@ -161,6 +165,13 @@ describe("Dashboard empty state", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("mounts the sidebar empty state on a fresh install with zero projects", () => {
render(<Dashboard initialSessions={[]} projects={[]} />);
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new project/i })).toBeInTheDocument();
});
it("shows empty state when only done sessions exist", () => {
render(
<Dashboard

View File

@ -32,8 +32,8 @@ describe("ProjectSidebar", () => {
vi.unstubAllGlobals();
});
it("renders nothing when there are no projects", () => {
const { container } = render(
it("renders the empty-state header with the + button when no projects are configured", () => {
render(
<ProjectSidebar
projects={[]}
sessions={[]}
@ -42,7 +42,67 @@ describe("ProjectSidebar", () => {
/>,
);
expect(container.firstChild).toBeNull();
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new project/i })).toBeInTheDocument();
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
it("opens AddProjectModal from the empty-state + button", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ entries: [] }),
});
vi.stubGlobal("fetch", fetchMock);
render(
<ProjectSidebar
projects={[]}
sessions={[]}
activeProjectId={undefined}
activeSessionId={undefined}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
await waitFor(() => {
expect(screen.getByRole("dialog", { name: /add project/i })).toBeInTheDocument();
});
});
it("renders the theme toggle in the empty-state footer", () => {
render(
<ProjectSidebar
projects={[]}
sessions={[]}
activeProjectId={undefined}
activeSessionId={undefined}
/>,
);
expect(screen.getByRole("button", { name: /switch to (dark|light) mode/i })).toBeInTheDocument();
});
it("renders a collapsed empty rail when collapsed with no projects", () => {
const { container } = render(
<ProjectSidebar
projects={[]}
sessions={[]}
activeProjectId={undefined}
activeSessionId={undefined}
collapsed
/>,
);
expect(container.querySelector(".project-sidebar--collapsed")).not.toBeNull();
// Header label, empty-state copy, and footer are hidden in the collapsed rail
expect(screen.queryByText("Projects")).not.toBeInTheDocument();
expect(screen.queryByText(/no projects yet/i)).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /switch to (dark|light) mode/i }),
).not.toBeInTheDocument();
// The + button is still reachable so users can add a project from the rail
expect(screen.getByRole("button", { name: /new project/i })).toBeInTheDocument();
});
it("renders the compact sidebar header and project rows", () => {