diff --git a/frontend/src/renderer/components/CommandPalette.test.tsx b/frontend/src/renderer/components/CommandPalette.test.tsx index 45b152970c..6e94f74e51 100644 --- a/frontend/src/renderer/components/CommandPalette.test.tsx +++ b/frontend/src/renderer/components/CommandPalette.test.tsx @@ -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(() => { @@ -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 }) => @@ -148,7 +149,7 @@ beforeEach(() => { ctx.params = {}; ctx.enabled = true; navigateMock.mockReset(); - spawnMock.mockReset(); + openOrEnsureMock.mockReset(); choosePathMock.mockReset(); act(() => { useUiStore.setState({ @@ -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 () => { @@ -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(); }); @@ -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(() => {})); + 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); }); diff --git a/frontend/src/renderer/components/CommandPalette.tsx b/frontend/src/renderer/components/CommandPalette.tsx index 7ef01f7ac5..79b16bac78 100644 --- a/frontend/src/renderer/components/CommandPalette.tsx +++ b/frontend/src/renderer/components/CommandPalette.tsx @@ -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, @@ -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"; @@ -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], diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index 7f6f518df4..e5d5d53bf1 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -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"; @@ -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 }, diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index cc07f83844..998760cd72 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -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"; @@ -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); diff --git a/frontend/src/renderer/lib/open-orchestrator.test.ts b/frontend/src/renderer/lib/open-orchestrator.test.ts new file mode 100644 index 0000000000..04f3a3cd0c --- /dev/null +++ b/frontend/src/renderer/lib/open-orchestrator.test.ts @@ -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 & { 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); + }); +}); diff --git a/frontend/src/renderer/lib/open-orchestrator.ts b/frontend/src/renderer/lib/open-orchestrator.ts new file mode 100644 index 0000000000..16ca992e1a --- /dev/null +++ b/frontend/src/renderer/lib/open-orchestrator.ts @@ -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; +}; + +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 { + 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 }; +}