From 699469bee4971fdb30c9448c02f59192a9e3b604 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Sun, 5 Jul 2026 18:37:25 +0530 Subject: [PATCH] fix(frontend): preserve project startup spawn errors --- .../integration/board-empty-states.test.tsx | 95 ++++++++++++++++++- .../src/renderer/components/SessionsBoard.tsx | 30 +++++- frontend/src/renderer/routes/_shell.tsx | 8 +- frontend/src/renderer/stores/ui-store.ts | 13 +++ 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/frontend/src/renderer/__tests__/integration/board-empty-states.test.tsx b/frontend/src/renderer/__tests__/integration/board-empty-states.test.tsx index 1e70fdfe0..ac1d9a570 100644 --- a/frontend/src/renderer/__tests__/integration/board-empty-states.test.tsx +++ b/frontend/src/renderer/__tests__/integration/board-empty-states.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; @@ -34,6 +34,7 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { import { SessionsBoard } from "../../components/SessionsBoard"; import { ShellProvider, type ShellContextValue } from "../../lib/shell-context"; +import { useUiStore } from "../../stores/ui-store"; type Project = { id: string; name: string; path: string }; type Session = Record; @@ -60,6 +61,18 @@ const workerSession: Session = { prs: [], }; +const orchestratorSession: Session = { + id: "proj-1-orchestrator", + projectId: "proj-1", + displayName: "orchestrator", + harness: "claude-code", + kind: "orchestrator", + status: "working", + isTerminated: false, + updatedAt: "2026-07-04T10:00:00Z", + prs: [], +}; + const createProjectMock = vi.fn().mockResolvedValue(undefined); // Kept from the latest renderBoard call so tests can rerender with the same @@ -86,6 +99,11 @@ const columnCount = () => document.querySelectorAll("section").length; beforeEach(() => { vi.clearAllMocks(); createProjectMock.mockResolvedValue(undefined); + useUiStore.setState({ + orchestratorReplacementErrors: {}, + orchestratorStartupErrors: {}, + restartingProjectIds: new Set(), + }); }); describe("global board first launch", () => { @@ -156,6 +174,81 @@ describe("project board with no sessions", () => { expect(await screen.findByText(/branch is already checked out/)).toBeInTheDocument(); }); + it("shows the project creation startup error after navigating to the project board", async () => { + respondWith([project], []); + useUiStore + .getState() + .setOrchestratorStartupError( + "proj-1", + "Project added, but orchestrator did not start: branch is already checked out in another worktree", + ); + renderBoard(); + + expect(await screen.findByText(/Project added, but orchestrator did not start/)).toBeInTheDocument(); + expect(screen.getByText(/branch is already checked out/)).toBeInTheDocument(); + }); + + it("clears the project creation startup error when retrying orchestrator spawn", async () => { + respondWith([project], []); + useUiStore + .getState() + .setOrchestratorStartupError( + "proj-1", + "Project added, but orchestrator did not start: branch is already checked out in another worktree", + ); + spawnOrchestratorMock.mockResolvedValue("proj-1-orchestrator"); + renderBoard(); + + await screen.findByText(/Project added, but orchestrator did not start/); + const [spawnButton] = screen.getAllByRole("button", { name: "Spawn Orchestrator" }); + await userEvent.click(spawnButton); + + await waitFor(() => + expect(screen.queryByText(/Project added, but orchestrator did not start/)).not.toBeInTheDocument(), + ); + expect(useUiStore.getState().orchestratorStartupErrors["proj-1"]).toBeUndefined(); + }); + + it("clears a project creation startup error when switching projects", async () => { + const otherProject: Project = { id: "proj-2", name: "other-app", path: "/repo/other-app" }; + respondWith([project, otherProject], []); + useUiStore + .getState() + .setOrchestratorStartupError( + "proj-1", + "Project added, but orchestrator did not start: branch is already checked out in another worktree", + ); + const { rerender } = renderBoard(); + + await screen.findByText(/Project added, but orchestrator did not start/); + rerender( + + + + + , + ); + + await screen.findByText("No sessions yet"); + await waitFor(() => expect(useUiStore.getState().orchestratorStartupErrors["proj-1"]).toBeUndefined()); + expect(screen.queryByText(/Project added, but orchestrator did not start/)).not.toBeInTheDocument(); + }); + + it("clears a project creation startup error once an orchestrator exists", async () => { + respondWith([project], [orchestratorSession]); + useUiStore + .getState() + .setOrchestratorStartupError( + "proj-1", + "Project added, but orchestrator did not start: branch is already checked out in another worktree", + ); + renderBoard(); + + await screen.findByText("No sessions yet"); + await waitFor(() => expect(useUiStore.getState().orchestratorStartupErrors["proj-1"]).toBeUndefined()); + expect(screen.queryByText(/Project added, but orchestrator did not start/)).not.toBeInTheDocument(); + }); + it("clears a stale spawn error when switching projects", async () => { const otherProject: Project = { id: "proj-2", name: "other-app", path: "/repo/other-app" }; respondWith([project, otherProject], []); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 3323e16ab..451666da7 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type KeyboardEvent } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { AlertTriangle, Plus, RotateCw } from "lucide-react"; @@ -88,13 +88,31 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const [isSpawning, setIsSpawning] = useState(false); const [spawnError, setSpawnError] = useState(null); const restartingProjectIds = useUiStore((state) => state.restartingProjectIds); + const orchestratorStartupError = useUiStore((state) => + projectId ? (state.orchestratorStartupErrors[projectId] ?? null) : null, + ); const setProjectRestarting = useUiStore((state) => state.setProjectRestarting); const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError); + const setOrchestratorStartupError = useUiStore((state) => state.setOrchestratorStartupError); const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false; const health = workspace ? orchestratorHealth(workspace, isProjectRestarting) : { state: "ok" as const }; + const visibleSpawnError = spawnError ?? orchestratorStartupError; // The board instance survives project-to-project navigation (same route, // new param), so a spawn failure must not follow the user to another board. useEffect(() => setSpawnError(null), [projectId]); + const previousProjectIdRef = useRef(projectId); + useEffect(() => { + const previousProjectId = previousProjectIdRef.current; + if (previousProjectId && previousProjectId !== projectId) { + setOrchestratorStartupError(previousProjectId, null); + } + previousProjectIdRef.current = projectId; + }, [projectId, setOrchestratorStartupError]); + useEffect(() => { + if (projectId && orchestrator && orchestratorStartupError) { + setOrchestratorStartupError(projectId, null); + } + }, [orchestrator, orchestratorStartupError, projectId, setOrchestratorStartupError]); const byZone = new Map(); for (const session of sessions) { @@ -129,10 +147,12 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { return; } setSpawnError(null); + setOrchestratorStartupError(projectId, null); setIsSpawning(true); try { const sessionId = await spawnOrchestrator(projectId); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + setOrchestratorStartupError(projectId, null); void navigate({ to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId }, @@ -169,9 +189,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const actions = projectId ? ( <> - {spawnError && !showProjectEmpty && ( - - {spawnError} + {visibleSpawnError && !showProjectEmpty && ( + + {visibleSpawnError} )}