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
27 changes: 16 additions & 11 deletions frontend/src/renderer/components/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { WorkspaceSummary } from "../types/workspace";
import { useUiStore } from "../stores/ui-store";

const navigateMock = vi.hoisted(() => vi.fn());
const spawnMock = vi.hoisted(() => vi.fn());
const openOrEnsureMock = vi.hoisted(() => vi.fn());
const choosePathMock = vi.hoisted(() => vi.fn());

const ctx = vi.hoisted(() => {
Expand Down Expand Up @@ -95,13 +95,14 @@ vi.mock("../hooks/useCommandPaletteEnabled", () => ({
vi.mock("../hooks/useWorkspaceQuery", () => ({
useWorkspaceQuery: () => ({ data: ctx.workspaces }),
workspaceQueryKey: ["workspaces"],
workspaceQueryOptions: { queryKey: ["workspaces"], queryFn: async () => ctx.workspaces },
}));

vi.mock("../lib/shell-context", () => ({
useShell: () => ({ createProject: vi.fn(), initializeProjectRepository: vi.fn(), daemonStatus: {} }),
}));

vi.mock("../lib/spawn-orchestrator", () => ({ spawnOrchestrator: spawnMock }));
vi.mock("../lib/open-orchestrator", () => ({ openOrEnsureOrchestrator: openOrEnsureMock }));

vi.mock("./NewTaskDialog", () => ({
NewTaskDialog: ({ open, projectId }: { open: boolean; projectId?: string }) =>
Expand Down Expand Up @@ -148,7 +149,7 @@ beforeEach(() => {
ctx.params = {};
ctx.enabled = true;
navigateMock.mockReset();
spawnMock.mockReset();
openOrEnsureMock.mockReset();
choosePathMock.mockReset();
act(() => {
useUiStore.setState({
Expand Down Expand Up @@ -314,7 +315,7 @@ describe("CommandPalette actions", () => {
});
fireEvent.click(screen.getByText("New task"));
expect(navigateMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
expect(openOrEnsureMock).not.toHaveBeenCalled();
});

it("does not spawn Open orchestrator while the project is restarting", async () => {
Expand All @@ -324,7 +325,7 @@ describe("CommandPalette actions", () => {
act(() => useUiStore.getState().setCommandPaletteOpen(true));
await screen.findByPlaceholderText(/search projects/i);
fireEvent.click(screen.getByText("Open orchestrator"));
expect(spawnMock).not.toHaveBeenCalled();
expect(openOrEnsureMock).not.toHaveBeenCalled();
expect(navigateMock).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -355,27 +356,31 @@ describe("CommandPalette actions", () => {
});
});

it("spawns only once when Open orchestrator is selected twice (in-flight guard)", async () => {
it("opens only once when Open orchestrator is selected twice (in-flight guard)", async () => {
ctx.params = { projectId: "proj-2" };
spawnMock.mockReturnValueOnce(new Promise<string>(() => {}));
openOrEnsureMock.mockReturnValueOnce(new Promise(() => {}));
renderPalette();
act(() => useUiStore.getState().setCommandPaletteOpen(true));
await screen.findByPlaceholderText(/search projects/i);
const item = screen.getByText("Open orchestrator");
fireEvent.click(item);
fireEvent.click(item);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(openOrEnsureMock).toHaveBeenCalledTimes(1);
});

it("keeps the palette open and shows an error when spawning an orchestrator fails", async () => {
it("keeps the palette open and shows an error when opening an orchestrator fails", async () => {
ctx.params = { projectId: "proj-2" };
spawnMock.mockRejectedValueOnce(new Error("daemon down"));
openOrEnsureMock.mockRejectedValueOnce(new Error("daemon down"));
renderPalette();
act(() => useUiStore.getState().setCommandPaletteOpen(true));
await screen.findByPlaceholderText(/search projects/i);
fireEvent.click(screen.getByText("Open orchestrator"));
expect(await screen.findByRole("alert")).toHaveTextContent("daemon down");
expect(spawnMock).toHaveBeenCalledWith("proj-2", "command_palette");
expect(openOrEnsureMock).toHaveBeenCalledWith(
"proj-2",
"command_palette",
expect.objectContaining({ workspaces: ctx.workspaces }),
);
expect(useUiStore.getState().isCommandPaletteOpen).toBe(true);
});

Expand Down
26 changes: 12 additions & 14 deletions frontend/src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { useCommandPaletteEnabled } from "../hooks/useCommandPaletteEnabled";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery";
import { aoBridge } from "../lib/bridge";
import {
buildCommands,
Expand All @@ -12,9 +12,8 @@ import {
type NavigateTarget,
} from "../lib/command-palette";
import { isDialogOrMenuOpen } from "../lib/dom-selectors";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { openOrEnsureOrchestrator } from "../lib/open-orchestrator";
import { useShell } from "../lib/shell-context";
import { findProjectOrchestrator } from "../types/workspace";
import { useUiStore } from "../stores/ui-store";
import { CreateProjectFlow } from "./CreateProjectFlow";
import { NewTaskDialog } from "./NewTaskDialog";
Expand Down Expand Up @@ -115,18 +114,17 @@ export function CommandPalette() {
const openOrchestrator = useCallback(
async (projectId: string) => {
if (blockedByRestart(projectId)) return;
const orchestrator = findProjectOrchestrator(workspaces, projectId);
if (orchestrator) {
navigateToTarget({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
});
closePalette();
return;
const { sessionId, didSpawn } = await openOrEnsureOrchestrator(projectId, "command_palette", {
workspaces,
refetchWorkspaces: () => queryClient.fetchQuery(workspaceQueryOptions),
});
if (didSpawn) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
}
const sessionId = await spawnOrchestrator(projectId, "command_palette");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
navigateToTarget({ to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId } });
navigateToTarget({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
closePalette();
},
[workspaces, navigateToTarget, queryClient, closePalette, blockedByRestart],
Expand Down
20 changes: 9 additions & 11 deletions frontend/src/renderer/components/ShellTopbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import {
sessionIsActive,
type WorkspaceSession,
} from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { openOrEnsureOrchestrator } from "../lib/open-orchestrator";
import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { useUiStore } from "../stores/ui-store";
import { OrchestratorIcon } from "./icons";
Expand Down Expand Up @@ -105,17 +105,15 @@ 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 });
const { sessionId, didSpawn } = await openOrEnsureOrchestrator(projectId, "topbar", {
workspaces: all,
refetchWorkspaces: () => queryClient.fetchQuery(workspaceQueryOptions),
});
if (didSpawn) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
}
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
Expand Down
21 changes: 11 additions & 10 deletions frontend/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
} 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 { workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery";
import { openOrEnsureOrchestrator } from "../lib/open-orchestrator";
import { renameSession } from "../lib/rename-session";
import { useResizable } from "../hooks/useResizable";
import { useShellMaybe } from "../lib/shell-context";
Expand Down Expand Up @@ -411,18 +411,19 @@ function ProjectItem({
// 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.
// Mirrors ShellTopbar's launcher: reuse the live orchestrator (refetch once
// if the cache looks empty), or spawn only when none is active.
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 });
const { sessionId, didSpawn } = await openOrEnsureOrchestrator(workspace.id, "sidebar", {
workspaces: [workspace],
refetchWorkspaces: () => queryClient.fetchQuery(workspaceQueryOptions),
});
if (didSpawn) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
}
selection.goSession(workspace.id, sessionId);
} catch (err) {
console.error("Failed to spawn orchestrator:", err);
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/renderer/lib/open-orchestrator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { openOrEnsureOrchestrator } from "./open-orchestrator";
import { spawnOrchestrator } from "./spawn-orchestrator";
import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace";

vi.mock("./spawn-orchestrator", () => ({
spawnOrchestrator: vi.fn(),
}));

const spawnMock = vi.mocked(spawnOrchestrator);

function orch(overrides: Partial<WorkspaceSession> & { id: string }): WorkspaceSession {
return {
workspaceId: "proj",
workspaceName: "Project",
title: "orchestrator",
provider: "claude-code",
kind: "orchestrator",
branch: "main",
status: "working",
updatedAt: "2026-01-02T00:00:00Z",
createdAt: "2026-01-02T00:00:00Z",
prs: [],
...overrides,
};
}

function workspace(sessions: WorkspaceSession[]): WorkspaceSummary {
return { id: "proj", name: "Project", path: "/tmp/proj", sessions };
}

describe("openOrEnsureOrchestrator", () => {
beforeEach(() => {
spawnMock.mockReset();
});

it("returns the cached live orchestrator without spawning", async () => {
const live = orch({ id: "proj-orch" });
const refetchWorkspaces = vi.fn();

const result = await openOrEnsureOrchestrator("proj", "topbar", {
workspaces: [workspace([live])],
refetchWorkspaces,
});

expect(result).toEqual({ sessionId: "proj-orch", didSpawn: false });
expect(refetchWorkspaces).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});

it("refetches once when cache is empty and reuses the live orchestrator", async () => {
const live = orch({ id: "proj-orch-live" });
const refetchWorkspaces = vi.fn().mockResolvedValue([workspace([live])]);

const result = await openOrEnsureOrchestrator("proj", "sidebar", {
workspaces: [workspace([])],
refetchWorkspaces,
});

expect(result).toEqual({ sessionId: "proj-orch-live", didSpawn: false });
expect(refetchWorkspaces).toHaveBeenCalledTimes(1);
expect(spawnMock).not.toHaveBeenCalled();
});

it("spawns once with clean=false when cache and refetch both find none", async () => {
spawnMock.mockResolvedValue("proj-orch-new");
const refetchWorkspaces = vi.fn().mockResolvedValue([workspace([])]);

const result = await openOrEnsureOrchestrator("proj", "command_palette", {
workspaces: [],
refetchWorkspaces,
});

expect(result).toEqual({ sessionId: "proj-orch-new", didSpawn: true });
expect(refetchWorkspaces).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("proj", "command_palette", false);
});

it("ignores terminated orchestrators in cache and after refetch", async () => {
const dead = orch({ id: "proj-dead", status: "terminated" });
spawnMock.mockResolvedValue("proj-orch-new");
const refetchWorkspaces = vi.fn().mockResolvedValue([workspace([dead])]);

const result = await openOrEnsureOrchestrator("proj", "topbar", {
workspaces: [workspace([dead])],
refetchWorkspaces,
});

expect(result).toEqual({ sessionId: "proj-orch-new", didSpawn: true });
expect(spawnMock).toHaveBeenCalledWith("proj", "topbar", false);
});
});
45 changes: 45 additions & 0 deletions frontend/src/renderer/lib/open-orchestrator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { findProjectOrchestrator, type WorkspaceSummary } from "../types/workspace";
import { spawnOrchestrator, type OrchestratorSpawnSource } from "./spawn-orchestrator";

export type OpenOrEnsureOrchestratorOptions = {
/** Current workspace query snapshot (may be stale). */
workspaces: WorkspaceSummary[];
/**
* Refetch workspaces once when the cache has no live orchestrator.
* Must return the refreshed list used for the second lookup.
*/
refetchWorkspaces: () => Promise<WorkspaceSummary[]>;
};

export type OpenOrEnsureOrchestratorResult = {
sessionId: string;
/** True only when POST /orchestrators (clean=false) was called. */
didSpawn: boolean;
};

/**
* Open the project's live orchestrator, or ensure one exists.
*
* Never passes clean=true. Casual clicks must reuse a non-terminated
* orchestrator when one exists — including when the client cache is briefly
* empty and a single refetch would find it.
*/
export async function openOrEnsureOrchestrator(
projectId: string,
source: OrchestratorSpawnSource,
options: OpenOrEnsureOrchestratorOptions,
): Promise<OpenOrEnsureOrchestratorResult> {
let orch = findProjectOrchestrator(options.workspaces, projectId);
if (orch) {
return { sessionId: orch.id, didSpawn: false };
}

const refreshed = await options.refetchWorkspaces();
orch = findProjectOrchestrator(refreshed, projectId);
if (orch) {
return { sessionId: orch.id, didSpawn: false };
}

const sessionId = await spawnOrchestrator(projectId, source, false);
return { sessionId, didSpawn: true };
}