diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx new file mode 100644 index 00000000..7747e531 --- /dev/null +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +// The Sidebar reads selection from the router; stub the three hooks it uses so +// the component renders without a full RouterProvider. +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({}), + useRouterState: () => "/", +})); + +import { TooltipProvider } from "./ui/tooltip"; +import { SidebarProvider } from "./ui/sidebar"; +import { Sidebar } from "./Sidebar"; +import type { WorkspaceSummary } from "../types/workspace"; + +const workspaces: WorkspaceSummary[] = [{ id: "proj-1", name: "my-app", path: "/p", type: "main", sessions: [] }]; + +function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) { + render( + + + + + , + ); + return onRemoveProject; +} + +describe("Sidebar", () => { + it("removes a project from the row kebab menu", async () => { + const user = userEvent.setup(); + const onRemoveProject = renderSidebar(); + + await user.click(screen.getByRole("button", { name: "Actions for my-app" })); + await user.click(await screen.findByText("Remove project")); + + expect(onRemoveProject).toHaveBeenCalledWith("proj-1"); + }); +}); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 8760f8ee..c069e213 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(); @@ -200,6 +213,7 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj expanded={!collapsedIds.has(workspace.id)} selection={selection} onToggle={() => toggleCollapsed(workspace.id)} + onRemoveProject={onRemoveProject} /> ))} @@ -302,13 +316,29 @@ 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 handleRemove = () => { + void (async () => { + 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) { + // The app has no toast surface yet; the 15s workspace refetch resyncs + // the sidebar, so log and let the row reappear if the delete failed. + console.error("remove project failed", err); + } + })(); + }; // 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); @@ -355,6 +385,30 @@ function ProjectItem({ {sessions.length} + {/* Per-project actions: a kebab that reveals on row hover (replacing the + session count) — the daemon/CLI already support removal, this surfaces + it in the UI. */} + + + + + + + selection.goSettings(workspace.id)}> + + + + + + {/* 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 ba911ba1..9c7cdbc9 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -119,6 +119,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; @@ -174,6 +185,7 @@ function ShellLayout() {