Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions frontend/src/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,27 +25,55 @@ 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(() => {
vi.restoreAllMocks();
});

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();

Expand Down
77 changes: 74 additions & 3 deletions frontend/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -28,6 +39,7 @@ import {
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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} />}
Expand Down Expand Up @@ -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);
Expand All @@ -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 */}
Expand All @@ -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",
)}
Expand All @@ -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. */}
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/renderer/routes/_shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
/>
Expand Down