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)}>
+
+ Project settings
+
+
+ void removeProject()}
+ >
+
+ Remove project
+
+
+
+ {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}
>