From aecf001599ed583781b93f6b0cf9f56f68d2d533 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 17 Jun 2026 21:40:15 +0530 Subject: [PATCH 1/2] feat(frontend): show dashboard and orchestrator buttons on project hover Hovering a project row in the sidebar now reveals a dashboard button (opens the project board), an orchestrator button (opens the running orchestrator or spawns one), and a vertical three-dot kebab menu, replacing the lone horizontal kebab. Closes #292. Co-Authored-By: Claude Opus 4.8 --- .../src/renderer/components/Sidebar.test.tsx | 45 ++++-- frontend/src/renderer/components/Sidebar.tsx | 130 ++++++++++++++---- 2 files changed, 135 insertions(+), 40 deletions(-) diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index cd71be74..854f7060 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -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"; @@ -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( - - - , + + + + + , ); return onRemoveProject; } @@ -74,6 +80,23 @@ 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(); @@ -81,9 +104,9 @@ describe("Sidebar", () => { 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"); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index c0878d8a..5082c3a4 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -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, @@ -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 { @@ -39,7 +44,6 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, - SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, @@ -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; @@ -367,11 +377,35 @@ function ProjectItem({ onRemoveProject: (projectId: string) => Promise; }) { const projectActive = selection.activeProjectId === workspace.id && !selection.activeSessionId; + const queryClient = useQueryClient(); const [removeError, setRemoveError] = useState(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) { @@ -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", )} @@ -439,30 +474,67 @@ function ProjectItem({ {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)}> - - - void removeProject()} - > - - - + {/* 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. */} +
+ + + + + Dashboard + + + + + + {isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"} + + + + + + + selection.goSettings(workspace.id)}> + + + void removeProject()} + > + + + +
{removeError && ( {removeError} From c0f619a6c6f5193d98dd0dfb96c6f2b789aaf498 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 16:10:44 +0000 Subject: [PATCH 2/2] chore: format with prettier [skip ci] --- frontend/src/renderer/components/Sidebar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 5082c3a4..d787e08e 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -510,7 +510,9 @@ function ProjectItem({