feat(sidebar): restore remove-project functionality (#225)

* feat(sidebar): restore remove-project functionality

#224 deleted the per-project "remove project" feature (and its tests)
instead of completing the wiring #222 left half-applied. Restore it:

- Sidebar: add onRemoveProject to SidebarProps, thread it to
  ProjectItem, re-add the removeError/isRemoving state and the confirm
  handler, render the "Project actions" kebab (MoreHorizontal) with a
  destructive "Remove project" item (Trash2), and re-add the row
  hover/focus/open padding + count-fade classes.
- _shell: re-add the removeProject handler (DELETE /api/v1/projects/{id}
  + drop the project from the workspace cache) and pass it to Sidebar.
- Sidebar.test: restore the confirm-before-remove and cancel-aborts
  coverage alongside the existing class assertions.

Rebuilt on top of #224. Full renderer suite green (127 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-13 13:48:24 +05:30 committed by GitHub
parent 680a85fceb
commit cc29d85f8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 118 additions and 8 deletions

View File

@ -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(
<SidebarProvider>
<Sidebar
daemonStatus={{ state: "running" }}
onCreateProject={vi.fn()}
onRemoveProject={onRemoveProject}
workspaces={[workspace]}
/>
</SidebarProvider>,
);
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();

View File

@ -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<void>;
onRemoveProject: (projectId: string) => Promise<void>;
};
// 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 && <CreateProjectListItem onCreateProject={onCreateProject} />}
@ -344,13 +358,17 @@ function ProjectItem({
expanded,
selection,
onToggle,
onRemoveProject,
}: {
workspace: WorkspaceSummary;
expanded: boolean;
selection: Selection;
onToggle: () => void;
onRemoveProject: (projectId: string) => Promise<void>;
}) {
const projectActive = selection.activeProjectId === workspace.id && !selection.activeSessionId;
const [removeError, setRemoveError] = useState<string | null>(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 (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
{/* 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({
/>
<span className="hidden group-data-[collapsible=icon]:block">{workspace.name.charAt(0).toUpperCase()}</span>
<span className="min-w-0 flex-1 truncate group-data-[collapsible=icon]:hidden">{workspace.name}</span>
<span className="shrink-0 font-mono text-[11px] text-passive group-hover/menu-item:opacity-0 group-data-[collapsible=icon]:hidden">
<span className="shrink-0 font-mono text-[11px] text-passive group-hover/menu-item:opacity-0 group-focus-within/menu-item:opacity-0 group-has-data-[state=open]/menu-item:opacity-0 group-data-[collapsible=icon]:hidden">
{sessions.length}
</span>
</SidebarMenuButton>
{/* Per-project actions: a kebab that reveals on row hover (replacing the
session count) surfaces the daemon/CLI removal capability in the UI. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover aria-label={`Project actions for ${workspace.name}`}>
<MoreHorizontal aria-hidden="true" />
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => selection.goSettings(workspace.id)}>
<Settings aria-hidden="true" />
Project settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive [&_svg]:text-destructive"
disabled={isRemoving}
onSelect={() => void removeProject()}
>
<Trash2 aria-hidden="true" />
Remove project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{removeError && (
<span className="sr-only" role="status">
{removeError}
</span>
)}
{/* 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. */}

View File

@ -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}
>
<Sidebar
daemonStatus={daemonStatus}
onCreateProject={createProject}
onRemoveProject={removeProject}
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}
workspaces={workspaces}
/>