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:
parent
703d5844f0
commit
3b9ba3122e
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue