diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index 8c26c23..cd71be7 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -1,5 +1,6 @@ import { SidebarProvider } from "@/components/ui/sidebar"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Sidebar } from "./Sidebar"; import type { WorkspaceSummary } from "../types/workspace"; @@ -24,20 +25,24 @@ const workspace: WorkspaceSummary = { sessions: [], }; -function renderSidebar() { +function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) { render( , ); + return onRemoveProject; } beforeEach(() => { navigateMock.mockReset(); + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.spyOn(window, "alert").mockImplementation(() => undefined); }); afterEach(() => { @@ -45,6 +50,30 @@ afterEach(() => { }); describe("Sidebar", () => { + it("confirms project removal before calling the remove handler", async () => { + const user = userEvent.setup(); + const onRemoveProject = renderSidebar(); + + await user.click(screen.getByLabelText("Project actions for Project One")); + await user.click(await screen.findByRole("menuitem", { name: "Remove project" })); + + expect(window.confirm).toHaveBeenCalledWith( + "Remove project Project One? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.", + ); + await waitFor(() => expect(onRemoveProject).toHaveBeenCalledTimes(1)); + }); + + it("does not remove the project when confirmation is cancelled", async () => { + vi.mocked(window.confirm).mockReturnValue(false); + const user = userEvent.setup(); + const onRemoveProject = renderSidebar(); + + await user.click(screen.getByLabelText("Project actions for Project One")); + await user.click(await screen.findByRole("menuitem", { name: "Remove project" })); + + expect(onRemoveProject).not.toHaveBeenCalled(); + }); + it("hides the worker count in every state that reveals project actions", () => { renderSidebar(); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 0397ee6..c0878d8 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -1,5 +1,16 @@ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; -import { ChevronRight, GitPullRequest, Moon, Plus, Search, Settings, Sun, Waypoints } from "lucide-react"; +import { + ChevronRight, + GitPullRequest, + Moon, + MoreHorizontal, + Plus, + Search, + Settings, + Sun, + Trash2, + Waypoints, +} from "lucide-react"; import { useState } from "react"; import { attentionZone, @@ -28,6 +39,7 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, @@ -52,6 +64,7 @@ type SidebarProps = { workspaceError?: string; workspaces: WorkspaceSummary[]; onCreateProject: (input: { path: string }) => Promise; + onRemoveProject: (projectId: string) => Promise; }; // Selection state comes from the URL: which project/session is active is the @@ -93,7 +106,7 @@ function SessionDot({ session }: { session: WorkspaceSession }) { // _shell owns open state (synced to the ui-store) and `collapsible="icon"` // replaces the old hand-rolled CollapsedRail — the same tree restyles itself // via group-data-[collapsible=icon] into the 48px letter rail. -export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProject }: SidebarProps) { +export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProject, onRemoveProject }: SidebarProps) { const selection = useSelection(); const eventsConnection = useEventsConnection(); const { state } = useSidebar(); @@ -201,6 +214,7 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj expanded={!collapsedIds.has(workspace.id)} selection={selection} onToggle={() => toggleCollapsed(workspace.id)} + onRemoveProject={onRemoveProject} /> ))} {isCollapsed && } @@ -344,13 +358,17 @@ function ProjectItem({ expanded, selection, onToggle, + onRemoveProject, }: { workspace: WorkspaceSummary; expanded: boolean; selection: Selection; onToggle: () => void; + onRemoveProject: (projectId: string) => Promise; }) { const projectActive = selection.activeProjectId === workspace.id && !selection.activeSessionId; + const [removeError, setRemoveError] = useState(null); + const [isRemoving, setIsRemoving] = useState(false); // Live workers only: merged/terminated sessions leave the sidebar and stay // reachable through the board's Done / Terminated bar (SessionsBoard). const sessions = workerSessions(workspace.sessions).filter(sessionIsActive); @@ -366,6 +384,27 @@ function ProjectItem({ } }; + const removeProject = async () => { + setRemoveError(null); + const confirmed = window.confirm( + `Remove project ${workspace.name}? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.`, + ); + if (!confirmed) return; + + setIsRemoving(true); + try { + await onRemoveProject(workspace.id); + // The route for a removed project no longer resolves; fall back home. + if (selection.activeProjectId === workspace.id) selection.goHome(); + } catch (err) { + const message = err instanceof Error ? err.message : "Could not remove project"; + setRemoveError(message); + window.alert(message); + } finally { + setIsRemoving(false); + } + }; + return ( {/* project-sidebar__proj-row */} @@ -379,6 +418,9 @@ function ProjectItem({ "h-auto gap-[9px] rounded-[5px] px-1.5 py-[7px] text-[13px] font-medium text-muted-foreground transition-[padding]", "hover:bg-interactive-hover hover:text-muted-foreground active:bg-interactive-hover active:text-muted-foreground", "data-[active=true]:bg-interactive-active data-[active=true]:font-semibold data-[active=true]:text-foreground", + // Make room for the kebab action when the row is hovered, focused, or + // its menu is open (the absolutely-positioned action replaces the count). + "group-hover/menu-item:pr-[34px] group-focus-within/menu-item:pr-[34px] group-has-data-[state=open]/menu-item:pr-[34px]", // Icon rail: the old 36px letter tile. "group-data-[collapsible=icon]:size-9! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:rounded-lg group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:font-semibold", )} @@ -393,10 +435,39 @@ function ProjectItem({ /> {workspace.name.charAt(0).toUpperCase()} {workspace.name} - + {sessions.length} + {/* Per-project actions: a kebab that reveals on row hover (replacing the + session count) — surfaces the daemon/CLI removal capability in the UI. */} + + + + + + + selection.goSettings(workspace.id)}> + + + void removeProject()} + > + + + + {removeError && ( + + {removeError} + + )} {/* project-sidebar__sessions. Divergence from AO (user decision 2026-06-12): no left indent or tree guide line — every sidebar row (project or worker) spans the same full width. */} diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index 9c2bbbc..fc276ee 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -66,6 +66,17 @@ function ShellLayout() { [navigate, updateWorkspaces], ); + const removeProject = useCallback( + async (projectId: string) => { + const { error } = await apiClient.DELETE("/api/v1/projects/{id}", { + params: { path: { id: projectId } }, + }); + if (error) throw new Error(apiErrorMessage(error)); + updateWorkspaces((current) => current.filter((item) => item.id !== projectId)); + }, + [updateWorkspaces], + ); + useEffect(() => { document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; @@ -114,13 +125,12 @@ function ShellLayout() { className="min-h-0 flex-1" onOpenChange={(open) => open !== isSidebarOpen && toggleSidebar()} open={isSidebarOpen} - style={ - { "--sidebar-width": "var(--ao-sidebar-w, 240px)", "--sidebar-width-icon": "48px" } as CSSProperties - } + style={{ "--sidebar-width": "var(--ao-sidebar-w, 240px)", "--sidebar-width-icon": "48px" } as CSSProperties} >