feat(web): add 'Open orchestrator' to sidebar 3-dot menu (#1615)

* feat(web): add 'Open orchestrator' to sidebar 3-dot menu

Adds a labeled menu entry above 'Project settings' that navigates to
the project's orchestrator session. The orchestrator is the most-used
session in any project, but today the only path to it is the unlabeled
icon button next to the dashboard icon - easy to miss for new users.

The entry is hidden when no live orchestrator exists (matching the
existing icon-button pattern), so the menu shrinks gracefully on
projects where 'ao start' has never run or has stopped.

Closes #1613

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

* refactor(web): drop redundant guard in orchestrator menu render

Hold the validated session in `liveOrchestrator` instead of a separate
boolean flag. TypeScript narrows automatically from the assignment, so
the render condition no longer needs `&& orchestratorSession` to satisfy
the type checker.

Addresses review feedback on #1613.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-05-03 20:39:57 +05:30 committed by GitHub
parent 703d5844f0
commit 3b9ba3122e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 118 additions and 1 deletions

View File

@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { cn } from "@/lib/cn";
import type { ProjectInfo } from "@/lib/project-name";
import { getAttentionLevel, type DashboardSession, type AttentionLevel } from "@/lib/types";
import { isOrchestratorSession } from "@aoagents/ao-core/types";
import { isOrchestratorSession, isTerminalSession } from "@aoagents/ao-core/types";
import { getSessionTitle, humanizeBranch } from "@/lib/format";
import { usePopoverClamp } from "@/hooks/usePopoverClamp";
import { getOrchestratorSessionId } from "@/lib/orchestrator-utils";
@ -397,6 +397,14 @@ function ProjectSidebarInner({
const orchestratorSession = sessions?.find(
(s) => s.projectId === project.id && s.id === canonicalOrchestratorId,
);
const liveOrchestrator =
orchestratorSession &&
!isTerminalSession({
status: orchestratorSession.status,
activity: orchestratorSession.activity,
})
? orchestratorSession
: null;
return (
<div key={project.id} className="project-sidebar__project">
@ -563,6 +571,22 @@ function ProjectSidebarInner({
role="menu"
aria-label={`${project.name} actions`}
>
{liveOrchestrator ? (
<button
type="button"
className="project-sidebar__proj-menu-item"
role="menuitem"
onClick={() => {
setProjectMenuOpenId(null);
navigate(
projectSessionPath(project.id, liveOrchestrator.id),
liveOrchestrator,
);
}}
>
Open orchestrator
</button>
) : null}
<button
type="button"
className="project-sidebar__proj-menu-item"

View File

@ -404,6 +404,99 @@ describe("ProjectSidebar", () => {
expect(screen.queryByText("Orchestrator")).not.toBeInTheDocument();
});
it("shows 'Open orchestrator' in the project actions menu when a live orchestrator exists", async () => {
render(
<ProjectSidebar
projects={projects}
sessions={[
makeSession({
id: "project-2-orchestrator",
projectId: "project-2",
summary: "Orchestrator",
metadata: { role: "orchestrator" },
status: "working",
}),
]}
activeProjectId="project-1"
activeSessionId={undefined}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Project actions for Project Two/i }));
expect(
await screen.findByRole("menuitem", { name: "Open orchestrator" }),
).toBeInTheDocument();
});
it("omits 'Open orchestrator' from the menu when no orchestrator session exists", async () => {
render(
<ProjectSidebar
projects={projects}
sessions={[]}
activeProjectId="project-1"
activeSessionId={undefined}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Project actions for Project Two/i }));
expect(await screen.findByRole("menuitem", { name: "Project settings" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Open orchestrator" })).not.toBeInTheDocument();
});
it("omits 'Open orchestrator' when the orchestrator session is terminal", async () => {
render(
<ProjectSidebar
projects={projects}
sessions={[
makeSession({
id: "project-2-orchestrator",
projectId: "project-2",
summary: "Orchestrator",
metadata: { role: "orchestrator" },
status: "killed",
activity: "exited",
}),
]}
activeProjectId="project-1"
activeSessionId={undefined}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Project actions for Project Two/i }));
expect(await screen.findByRole("menuitem", { name: "Project settings" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Open orchestrator" })).not.toBeInTheDocument();
});
it("navigates to the orchestrator session when 'Open orchestrator' is clicked", async () => {
render(
<ProjectSidebar
projects={projects}
sessions={[
makeSession({
id: "project-2-orchestrator",
projectId: "project-2",
summary: "Orchestrator",
metadata: { role: "orchestrator" },
status: "working",
}),
]}
activeProjectId="project-1"
activeSessionId={undefined}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Project actions for Project Two/i }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Open orchestrator" }));
expect(mockPush).toHaveBeenCalledWith("/projects/project-2/sessions/project-2-orchestrator");
await waitFor(() => {
expect(screen.queryByRole("menuitem", { name: "Open orchestrator" })).not.toBeInTheDocument();
});
});
it("renders the collapsed rail when collapsed", () => {
const { container } = render(
<ProjectSidebar