Skip to content
Open
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
50 changes: 11 additions & 39 deletions frontend/src/renderer/components/ShellTopbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,11 @@ import { useNavigate, useParams } from "@tanstack/react-router";
import { GitBranch, LayoutDashboard, PanelRightClose, PanelRightOpen, Plus, Square, Trash2 } from "lucide-react";
import { useState } from "react";
import { NotificationCenter } from "./NotificationCenter";
import {
findProjectOrchestrator,
isOrchestratorSession,
sessionIsActive,
type WorkspaceSession,
} from "../types/workspace";
import { isOrchestratorSession, sessionIsActive, type WorkspaceSession } from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { useOpenOrchestrator } from "../hooks/useOpenOrchestrator";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { addRendererExceptionStep, captureRendererEvent } from "../lib/telemetry";
import { useUiStore } from "../stores/ui-store";
import { OrchestratorIcon } from "./icons";
import { cn } from "../lib/utils";
Expand Down Expand Up @@ -49,7 +44,6 @@ const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperti
// agent-orchestrator keeps those as two components aligned only by CSS.
export function ShellTopbar() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string };
const currentSessionId = params.sessionId;
const isInspectorOpen = useUiStore((state) =>
Expand All @@ -58,7 +52,6 @@ export function ShellTopbar() {
const toggleInspector = useUiStore((state) => state.toggleInspector);
const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
const requestNewTask = useUiStore((state) => state.requestNewTask);
const [isSpawning, setIsSpawning] = useState(false);
const all = useWorkspaceQuery().data ?? [];

const session = params.sessionId
Expand All @@ -76,8 +69,12 @@ export function ShellTopbar() {
const isRootBoardRoute = !isSessionRoute && !isProjectBoardRoute;
const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined;
const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator");
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false;
const { orchestrator, openOrchestrator, isSpawning } = useOpenOrchestrator(
projectId ?? "",
project?.sessions ?? [],
"topbar",
);

if (isLinux && !isSessionRoute) {
return null;
Expand All @@ -96,7 +93,7 @@ export function ShellTopbar() {
toggleInspector(currentSessionId);
};

const openOrchestrator = async () => {
const handleOpenOrchestrator = () => {
if (!projectId) return;
void addRendererExceptionStep("Orchestrator open requested", {
source: "orchestrator-open",
Expand All @@ -105,32 +102,7 @@ export function ShellTopbar() {
project_id: projectId,
});
void captureRendererEvent("ao.renderer.orchestrator_open_requested", { project_id: projectId });
if (orchestrator) {
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
});
return;
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(projectId, "topbar");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
} catch (error) {
void captureRendererException(error, {
source: "orchestrator-open",
operation: "open_orchestrator",
surface: isSessionRoute ? "session_detail" : "project_board",
project_id: projectId,
});
console.error("Failed to spawn orchestrator:", error);
} finally {
setIsSpawning(false);
}
void openOrchestrator();
};

return (
Expand Down Expand Up @@ -210,7 +182,7 @@ export function ShellTopbar() {
<TopbarButton
aria-label="Open orchestrator"
disabled={isSpawning || isProjectRestarting}
onClick={() => void openOrchestrator()}
onClick={() => void handleOpenOrchestrator()}
style={noDragStyle}
variant="primary"
>
Expand Down
102 changes: 94 additions & 8 deletions frontend/src/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,26 @@ import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace";
import { agentsQueryKey } from "../hooks/useAgentsQuery";
import { useUiStore } from "../stores/ui-store";

const { getMock, navigateMock, mockParams, renameSessionMock, updateStatusMock } = vi.hoisted(() => ({
getMock: vi.fn(),
navigateMock: vi.fn(),
mockParams: { projectId: undefined as string | undefined },
renameSessionMock: vi.fn().mockResolvedValue(undefined),
updateStatusMock: vi.fn(),
}));
const { getMock, navigateMock, mockParams, renameSessionMock, spawnOrchestratorMock, updateStatusMock } = vi.hoisted(
() => ({
getMock: vi.fn(),
navigateMock: vi.fn(),
mockParams: { projectId: undefined as string | undefined, sessionId: undefined as string | undefined },
renameSessionMock: vi.fn().mockResolvedValue(undefined),
spawnOrchestratorMock: vi.fn(),
updateStatusMock: vi.fn(),
}),
);

vi.mock("../lib/rename-session", () => ({ renameSession: renameSessionMock }));
vi.mock("../lib/spawn-orchestrator", () => ({ spawnOrchestrator: spawnOrchestratorMock }));

vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: () => navigateMock,
useParams: () => ({}),
useParams: () => mockParams,
useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) =>
select({ location: { pathname: "/" } }),
};
Expand Down Expand Up @@ -70,6 +74,19 @@ const session: WorkspaceSession = {
prs: [],
};

const orchestrator: WorkspaceSession = {
id: "proj-1-orch",
workspaceId: "proj-1",
workspaceName: "Project One",
title: "orchestrator",
provider: "claude-code",
kind: "orchestrator",
branch: "main",
status: "working",
updatedAt: "2026-06-30T00:00:00Z",
prs: [],
};

type CreateProjectInput = {
path: string;
workerAgent: string;
Expand Down Expand Up @@ -194,8 +211,10 @@ beforeEach(() => {
});
navigateMock.mockReset();
renameSessionMock.mockReset().mockResolvedValue(undefined);
spawnOrchestratorMock.mockReset();
updateStatusMock.mockReset().mockResolvedValue({ state: "idle" });
mockParams.projectId = undefined;
mockParams.sessionId = undefined;
});

afterEach(() => {
Expand Down Expand Up @@ -908,4 +927,71 @@ describe("Sidebar", () => {
}
expect(screen.getByText("v9.9.9 ready")).toBeInTheDocument();
});

it("pins an orchestrator row at the top of the project's session group", () => {
renderSidebar({
workspaces: [{ ...workspace, sessions: [orchestrator, session] }],
});

const orchestratorRow = screen.getByLabelText("Open Orchestrator");
const workerRow = screen.getByLabelText("Open fix login");
expect(orchestratorRow).toBeInTheDocument();
expect(workerRow).toBeInTheDocument();
expect(workerRow.compareDocumentPosition(orchestratorRow) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
});

it("navigates to the live orchestrator session when the pinned row is clicked", async () => {
const user = userEvent.setup();
renderSidebar({
workspaces: [{ ...workspace, sessions: [orchestrator, session] }],
});

await user.click(screen.getByLabelText("Open Orchestrator"));

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

it("spawns an orchestrator and navigates to it when the pinned row is clicked and none exists", async () => {
const user = userEvent.setup();
spawnOrchestratorMock.mockResolvedValue("proj-1-orch-spawned");
renderSidebar({ workspaces: [{ ...workspace, sessions: [] }] });

await user.click(screen.getByLabelText("Spawn Orchestrator"));

await waitFor(() => expect(spawnOrchestratorMock).toHaveBeenCalledWith("proj-1", "sidebar"));
expect(navigateMock).toHaveBeenCalledWith({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId: "proj-1", sessionId: "proj-1-orch-spawned" },
});
});

it("shows the orchestrator's live status dot from its attention zone", () => {
renderSidebar({
workspaces: [
{
...workspace,
sessions: [{ ...orchestrator, status: "needs_input" }, session],
},
],
});

const dot = screen.getByLabelText("Open Orchestrator").querySelector('span[aria-hidden="true"]');
expect(dot).toHaveClass("bg-warning");
expect(dot).not.toHaveClass("bg-working");
});

it("marks the orchestrator row as active when its session is selected", () => {
mockParams.projectId = "proj-1";
mockParams.sessionId = "proj-1-orch";
renderSidebar({
workspaces: [{ ...workspace, sessions: [orchestrator, session] }],
});

const row = screen.getByLabelText("Open Orchestrator");
expect(row).toHaveAttribute("aria-current", "page");
expect(row).toHaveClass("before:bg-accent");
});
});
93 changes: 60 additions & 33 deletions frontend/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,11 @@ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
import { ChevronRight, LayoutDashboard, MoreVertical, Pencil, Plus, RefreshCw, Settings, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { UpdateStatus } from "../../main/update-settings";
import {
newestActiveOrchestrator,
sessionIsActive,
type WorkspaceSession,
type WorkspaceSummary,
workerSessions,
} from "../types/workspace";
import { sessionIsActive, type WorkspaceSession, type WorkspaceSummary, workerSessions } from "../types/workspace";
import { getSessionDotView } from "../lib/session-presentation";
import { aoBridge } from "../lib/bridge";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { useOpenOrchestrator } from "../hooks/useOpenOrchestrator";
import { renameSession } from "../lib/rename-session";
import { useResizable } from "../hooks/useResizable";
import { useUpdateStatus } from "../hooks/useUpdateStatus";
Expand Down Expand Up @@ -365,40 +359,22 @@ 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 [confirmOpen, setConfirmOpen] = useState(false);
const [isSpawning, setIsSpawning] = useState(false);
const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
const isProjectRestarting = restartingProjectIds.has(workspace.id);
const requestNewTask = useUiStore((state) => state.requestNewTask);
// 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 = newestActiveOrchestrator(workspace.sessions);

// 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 (isProjectRestarting) return;
if (orchestrator) {
selection.goSession(workspace.id, orchestrator.id);
return;
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(workspace.id, "sidebar");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
selection.goSession(workspace.id, sessionId);
} catch (err) {
console.error("Failed to spawn orchestrator:", err);
} finally {
setIsSpawning(false);
}
};
// button and the pinned orchestrator row in the session tree.
const { orchestrator, openOrchestrator, isSpawning } = useOpenOrchestrator(
workspace.id,
workspace.sessions,
"sidebar",
);

const onProjectClick = () => {
if (!expanded) {
Expand Down Expand Up @@ -539,8 +515,14 @@ function ProjectItem({
</div>
{/* project-sidebar__sessions: indented under the project parent so worker
sessions read as children without adding a persistent guide rail. */}
{expanded && sessions.length > 0 && (
{expanded && (
<SidebarMenuSub className="sidebar-expanded-chrome mx-0 ml-4.5 translate-x-0 gap-0 border-l-0 px-0 py-1 pl-2.5">
<OrchestratorRow
orchestrator={orchestrator}
active={Boolean(orchestrator) && selection.activeSessionId === orchestrator?.id}
disabled={isSpawning || isProjectRestarting}
onOpen={() => void openOrchestrator()}
/>
{sessions.map((session) => (
<SessionRow
key={session.id}
Expand Down Expand Up @@ -680,6 +662,51 @@ function SessionRow({ session, active, onOpen }: { session: WorkspaceSession; ac
);
}

// Pinned orchestrator row at the top of a project's session group. Shows the
// same live status dot as worker rows and navigates to the orchestrator session,
// spawning one first when none is running.
function OrchestratorRow({
orchestrator,
active,
disabled,
onOpen,
}: {
orchestrator?: WorkspaceSession;
active: boolean;
disabled?: boolean;
onOpen: () => void;
}) {
return (
<SidebarMenuSubItem>
<button
aria-current={active ? "page" : undefined}
aria-label={orchestrator ? "Open Orchestrator" : "Spawn Orchestrator"}
className={cn(
"relative flex h-auto w-full items-center gap-2.25 rounded-sm py-1.25 pl-2.5 pr-1.5 text-left outline-hidden transition-[color]",
"before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
"disabled:pointer-events-none disabled:opacity-50",
active && "text-foreground before:bg-accent",
)}
disabled={disabled}
onClick={onOpen}
type="button"
>
{orchestrator ? (
<SessionDot session={orchestrator} />
) : (
<span aria-hidden="true" className="mt-px h-1.5 w-1.5 shrink-0 rounded-full bg-passive" />
)}
<span className="min-w-0 flex-1">
<span className={cn("block truncate text-xs", active ? "text-foreground" : "text-muted-foreground")}>
Orchestrator
</span>
</span>
</button>
</SidebarMenuSubItem>
);
}

// RestartToUpdateRow sits directly above the Settings row when an update is
// downloaded and staged. Transparent while fresh; orange (working tokens) once
// the main-process evaluator flags it escalated. Clicking installs immediately;
Expand Down
Loading
Loading