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 settings
+
+
+
+
+ Remove project
+
+
+
{/* 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() {