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
45 changes: 34 additions & 11 deletions frontend/src/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SidebarProvider } from "@/components/ui/sidebar";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -26,15 +27,20 @@ const workspace: WorkspaceSummary = {
};

function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<SidebarProvider>
<Sidebar
daemonStatus={{ state: "running" }}
onCreateProject={vi.fn()}
onRemoveProject={onRemoveProject}
workspaces={[workspace]}
/>
</SidebarProvider>,
<QueryClientProvider client={queryClient}>
<SidebarProvider>
<Sidebar
daemonStatus={{ state: "running" }}
onCreateProject={vi.fn()}
onRemoveProject={onRemoveProject}
workspaces={[workspace]}
/>
</SidebarProvider>
</QueryClientProvider>,
);
return onRemoveProject;
}
Expand Down Expand Up @@ -74,16 +80,33 @@ describe("Sidebar", () => {
expect(onRemoveProject).not.toHaveBeenCalled();
});

it("reveals dashboard and orchestrator buttons alongside the kebab on the project row", () => {
renderSidebar();

expect(screen.getByLabelText("Open Project One dashboard")).toBeInTheDocument();
expect(screen.getByLabelText("Spawn Project One orchestrator")).toBeInTheDocument();
expect(screen.getByLabelText("Project actions for Project One")).toBeInTheDocument();
});

it("navigates to the project board when the dashboard button is clicked", async () => {
const user = userEvent.setup();
renderSidebar();

await user.click(screen.getByLabelText("Open Project One dashboard"));

expect(navigateMock).toHaveBeenCalledWith({ to: "/projects/$projectId", params: { projectId: "proj-1" } });
});

it("hides the worker count in every state that reveals project actions", () => {
renderSidebar();

const projectRow = screen.getByText("Project One").closest("button");
const count = screen.getByText("0");

if (!projectRow) throw new Error("Project row button not found");
expect(projectRow).toHaveClass("group-hover/menu-item:pr-[34px]");
expect(projectRow).toHaveClass("group-focus-within/menu-item:pr-[34px]");
expect(projectRow).toHaveClass("group-has-data-[state=open]/menu-item:pr-[34px]");
expect(projectRow).toHaveClass("group-hover/menu-item:pr-[78px]");
expect(projectRow).toHaveClass("group-focus-within/menu-item:pr-[78px]");
expect(projectRow).toHaveClass("group-has-data-[state=open]/menu-item:pr-[78px]");
expect(count).toHaveClass("group-hover/menu-item:opacity-0");
expect(count).toHaveClass("group-focus-within/menu-item:opacity-0");
expect(count).toHaveClass("group-has-data-[state=open]/menu-item:opacity-0");
Expand Down
132 changes: 103 additions & 29 deletions frontend/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
import {
ChevronRight,
GitPullRequest,
LayoutGrid,
Moon,
MoreHorizontal,
MoreVertical,
Plus,
Search,
Settings,
Expand All @@ -14,12 +16,15 @@ import {
import { useState } from "react";
import {
attentionZone,
isOrchestratorSession,
sessionIsActive,
type WorkspaceSession,
type WorkspaceSummary,
workerSessions,
} from "../types/workspace";
import { aoBridge } from "../lib/bridge";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { useEventsConnection } from "../hooks/useEventsConnection";
import { useResizable } from "../hooks/useResizable";
import {
Expand All @@ -39,7 +44,6 @@ import {
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
Expand All @@ -59,6 +63,12 @@ import { useUiStore } from "../stores/ui-store";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;

// Shared styling for the per-project hover action buttons (dashboard,
// orchestrator, kebab): a 20px square icon button that tints on hover, matching
// the old SidebarMenuAction footprint.
const HOVER_ACTION_CLASS =
"grid size-5 shrink-0 place-items-center rounded-md text-passive transition-colors hover:bg-interactive-hover hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-interactive-hover data-[state=open]:text-foreground [&_svg]:size-[15px]";

type SidebarProps = {
daemonStatus: { state: string; message?: string };
workspaceError?: string;
Expand Down Expand Up @@ -367,11 +377,35 @@ function ProjectItem({
onRemoveProject: (projectId: string) => Promise<void>;
}) {
const projectActive = selection.activeProjectId === workspace.id && !selection.activeSessionId;
const queryClient = useQueryClient();
const [removeError, setRemoveError] = useState<string | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
const [isSpawning, setIsSpawning] = 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);
// The project's live orchestrator (if any) backs the hover Orchestrator
// button: navigate to it when present, otherwise spawn one first.
const orchestrator = workspace.sessions.find((s) => isOrchestratorSession(s) && sessionIsActive(s));

// Mirrors ShellTopbar's launcher: attach to the running orchestrator, or
// spawn one via the daemon and follow it once the workspace refetches.
const openOrchestrator = async () => {
if (orchestrator) {
selection.goSession(workspace.id, orchestrator.id);
return;
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(workspace.id);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
selection.goSession(workspace.id, sessionId);
} catch (err) {
console.error("Failed to spawn orchestrator:", err);
} finally {
setIsSpawning(false);
}
};

const onProjectClick = () => {
if (!expanded) {
Expand Down Expand Up @@ -418,9 +452,10 @@ 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]",
// Make room for the hover actions (dashboard, orchestrator, kebab)
// when the row is hovered, focused, or its menu is open (the
// absolutely-positioned cluster replaces the count).
"group-hover/menu-item:pr-[78px] group-focus-within/menu-item:pr-[78px] group-has-data-[state=open]/menu-item:pr-[78px]",
// 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 @@ -439,30 +474,69 @@ function ProjectItem({
{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>
{/* Per-project hover actions: dashboard board, orchestrator, and a kebab
menu. The cluster reveals on row hover/focus (or while the kebab is
open), replacing the session count, and stays hidden in the icon rail. */}
<div
className={cn(
"absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-px",
"opacity-0 transition-opacity",
"group-hover/menu-item:opacity-100 group-focus-within/menu-item:opacity-100 group-has-data-[state=open]/menu-item:opacity-100",
"group-data-[collapsible=icon]:hidden",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Open ${workspace.name} dashboard`}
className={HOVER_ACTION_CLASS}
onClick={() => selection.goProject(workspace.id)}
type="button"
>
<LayoutGrid aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Dashboard</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={orchestrator ? `Open ${workspace.name} orchestrator` : `Spawn ${workspace.name} orchestrator`}
className={HOVER_ACTION_CLASS}
disabled={isSpawning}
onClick={() => void openOrchestrator()}
type="button"
>
<Waypoints aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>
{isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label={`Project actions for ${workspace.name}`} className={HOVER_ACTION_CLASS} type="button">
<MoreVertical aria-hidden="true" />
</button>
</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>
</div>
{removeError && (
<span className="sr-only" role="status">
{removeError}
Expand Down