fix(frontend): preserve project startup spawn errors
This commit is contained in:
parent
3ce3d7b0ab
commit
699469bee4
|
|
@ -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<string, unknown>;
|
||||
|
|
@ -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(<SessionsBoard projectId="proj-1" />);
|
||||
|
||||
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(<SessionsBoard projectId="proj-1" />);
|
||||
|
||||
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(<SessionsBoard projectId="proj-1" />);
|
||||
|
||||
await screen.findByText(/Project added, but orchestrator did not start/);
|
||||
rerender(
|
||||
<QueryClientProvider client={lastQueryClient!}>
|
||||
<ShellProvider value={lastShell!}>
|
||||
<SessionsBoard projectId="proj-2" />
|
||||
</ShellProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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(<SessionsBoard projectId="proj-1" />);
|
||||
|
||||
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], []);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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<AttentionZone, WorkspaceSession[]>();
|
||||
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 && (
|
||||
<span className="dashboard-app-header__kill-error max-w-[320px] truncate" title={spawnError}>
|
||||
{spawnError}
|
||||
{visibleSpawnError && !showProjectEmpty && (
|
||||
<span className="dashboard-app-header__kill-error max-w-[320px] truncate" title={visibleSpawnError}>
|
||||
{visibleSpawnError}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -240,7 +260,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
isProjectRestarting={isProjectRestarting}
|
||||
onNewTask={() => setIsNewTaskOpen(true)}
|
||||
onOpenOrchestrator={() => void openOrchestrator()}
|
||||
spawnError={spawnError}
|
||||
spawnError={visibleSpawnError}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full grid-cols-4 gap-2">
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ function ShellLayout() {
|
|||
const setProjectRestarting = useUiStore((state) => state.setProjectRestarting);
|
||||
const orchestratorReplacementErrors = useUiStore((state) => state.orchestratorReplacementErrors);
|
||||
const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError);
|
||||
const setOrchestratorStartupError = useUiStore((state) => state.setOrchestratorStartupError);
|
||||
const replacementErrorProjectId = Object.keys(orchestratorReplacementErrors)[0] ?? null;
|
||||
|
||||
const updateWorkspaces = useCallback(
|
||||
|
|
@ -111,6 +112,7 @@ function ShellLayout() {
|
|||
};
|
||||
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
|
||||
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
|
||||
setOrchestratorStartupError(workspace.id, null);
|
||||
try {
|
||||
const sessionId = await spawnOrchestrator(workspace.id);
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
|
|
@ -121,10 +123,12 @@ function ShellLayout() {
|
|||
} catch (spawnError) {
|
||||
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
|
||||
const message = spawnError instanceof Error ? spawnError.message : "Could not start orchestrator";
|
||||
throw new Error(`Project added, but orchestrator did not start: ${message}`);
|
||||
const startupMessage = `Project added, but orchestrator did not start: ${message}`;
|
||||
setOrchestratorStartupError(workspace.id, startupMessage);
|
||||
throw new Error(startupMessage);
|
||||
}
|
||||
},
|
||||
[navigate, queryClient, updateWorkspaces],
|
||||
[navigate, queryClient, setOrchestratorStartupError, updateWorkspaces],
|
||||
);
|
||||
|
||||
const removeProject = useCallback(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type UiState = {
|
|||
theme: Theme;
|
||||
restartingProjectIds: ReadonlySet<string>;
|
||||
orchestratorReplacementErrors: Record<string, string>;
|
||||
orchestratorStartupErrors: Record<string, string>;
|
||||
setWorkbenchTab: (tab: WorkbenchTab) => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
|
|
@ -22,6 +23,7 @@ type UiState = {
|
|||
toggleInspector: () => void;
|
||||
setProjectRestarting: (projectId: string, restarting: boolean) => void;
|
||||
setOrchestratorReplacementError: (projectId: string, message: string | null) => void;
|
||||
setOrchestratorStartupError: (projectId: string, message: string | null) => void;
|
||||
};
|
||||
|
||||
const sidebarStorageKey = "ao.sidebar.open";
|
||||
|
|
@ -64,6 +66,7 @@ export const useUiStore = create<UiState>((set) => ({
|
|||
theme: initialTheme(),
|
||||
restartingProjectIds: new Set<string>(),
|
||||
orchestratorReplacementErrors: {},
|
||||
orchestratorStartupErrors: {},
|
||||
setWorkbenchTab: (workbenchTab) => set({ workbenchTab }),
|
||||
setTheme: (theme) => {
|
||||
getLocalStorage()?.setItem(themeStorageKey, theme);
|
||||
|
|
@ -107,4 +110,14 @@ export const useUiStore = create<UiState>((set) => ({
|
|||
}
|
||||
return { orchestratorReplacementErrors };
|
||||
}),
|
||||
setOrchestratorStartupError: (projectId, message) =>
|
||||
set((state) => {
|
||||
const orchestratorStartupErrors = { ...state.orchestratorStartupErrors };
|
||||
if (message) {
|
||||
orchestratorStartupErrors[projectId] = message;
|
||||
} else {
|
||||
delete orchestratorStartupErrors[projectId];
|
||||
}
|
||||
return { orchestratorStartupErrors };
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue