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:
parent
d0fde88f2a
commit
eb06a4d090
|
|
@ -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)} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue