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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {

import { SessionsBoard } from "../../components/SessionsBoard";
import { PullRequestsPage } from "../../components/PullRequestsPage";
import { ShellProvider } from "../../lib/shell-context";

// One ordinary project with one worker session that has multiple PRs.
function respondWithProjectAndPRs() {
Expand Down Expand Up @@ -75,7 +76,13 @@ function respondWithProjectAndPRs() {

function renderWithProviders(node: ReactNode) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
render(
<QueryClientProvider client={queryClient}>
<ShellProvider value={{ daemonStatus: { state: "ready", port: 1234 }, createProject: vi.fn() }}>
{node}
</ShellProvider>
</QueryClientProvider>,
);
}

beforeEach(() => {
Expand Down
88 changes: 88 additions & 0 deletions frontend/src/renderer/components/SessionsBoard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ShellProvider } from "../lib/shell-context";
import { SessionsBoard } from "./SessionsBoard";

const { getMock, postMock, navigateMock } = vi.hoisted(() => ({
getMock: vi.fn(),
postMock: vi.fn(),
navigateMock: vi.fn(),
}));

vi.mock("../lib/api-client", () => ({
apiClient: { GET: getMock, POST: postMock },
apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"),
hasTrustedApiBaseUrl: () => true,
setApiBaseUrl: vi.fn(),
}));

vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return { ...actual, useNavigate: () => navigateMock };
});

function mockWorkspace() {
getMock.mockImplementation(async (url: string) => {
if (url === "/api/v1/projects") {
return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined };
}
if (url === "/api/v1/sessions") {
return { data: { sessions: [] }, error: undefined };
}
throw new Error(`unexpected GET ${url}`);
});
}

function renderBoard(status: { state: "ready" | "stopped"; port?: number; message?: string }) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<ShellProvider value={{ daemonStatus: status, createProject: vi.fn() }}>
<SessionsBoard projectId="proj-1" />
</ShellProvider>
</QueryClientProvider>,
);
}

beforeEach(() => {
getMock.mockReset();
postMock.mockReset();
navigateMock.mockReset();
mockWorkspace();
window.ao!.daemon.start = vi.fn().mockResolvedValue({ state: "ready", port: 4567 });
});

describe("SessionsBoard daemon recovery", () => {
it("disables daemon-backed project actions while the daemon is stopped", async () => {
renderBoard({ state: "stopped" });

expect(await screen.findByRole("button", { name: "New task" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Spawn Orchestrator" })).toBeDisabled();
expect(
screen.getByText("AO daemon is stopped. Restart it to create tasks or start an orchestrator."),
).toBeInTheDocument();
});

it("offers an explicit daemon restart action", async () => {
renderBoard({ state: "stopped" });

await userEvent.click(await screen.findByRole("button", { name: "Restart daemon" }));

await waitFor(() => expect(window.ao!.daemon.start).toHaveBeenCalledTimes(1));
});

it("surfaces spawn errors instead of leaking an unhandled rejection", async () => {
postMock.mockResolvedValue({
data: undefined,
error: { message: "AO daemon is not ready" },
response: { status: 503 },
});
renderBoard({ state: "ready", port: 4567 });

await userEvent.click(await screen.findByRole("button", { name: "Spawn Orchestrator" }));

expect(await screen.findByRole("alert")).toHaveTextContent("AO daemon is not ready");
});
});
56 changes: 54 additions & 2 deletions frontend/src/renderer/components/SessionsBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery
import { DashboardSubhead } from "./DashboardSubhead";
import { OrchestratorIcon } from "./icons";
import { NewTaskDialog } from "./NewTaskDialog";
import { startDaemon } from "../lib/daemon-status";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display";
import { useShell } from "../lib/shell-context";
import { cn } from "../lib/utils";
import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";

Expand Down Expand Up @@ -67,6 +69,7 @@ const COLUMNS: Column[] = [
export function SessionsBoard({ projectId }: SessionsBoardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { daemonStatus } = useShell();
const workspaceQuery = useWorkspaceQuery();
const all = workspaceQuery.data ?? [];
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
Expand All @@ -76,6 +79,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
: undefined;
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
const [isSpawning, setIsSpawning] = useState(false);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const daemonReady = daemonStatus.state === "ready";

const byZone = new Map<AttentionZone, WorkspaceSession[]>();
for (const session of sessions) {
Expand All @@ -95,6 +101,10 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {

const openOrchestrator = async () => {
if (!projectId) return;
if (!daemonReady) {
setActionError("AO daemon is stopped. Restart it before starting sessions.");
return;
}
if (orchestrator) {
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
Expand All @@ -103,18 +113,34 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
return;
}
setIsSpawning(true);
setActionError(null);
try {
const sessionId = await spawnOrchestrator(projectId);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
} catch (error) {
setActionError(error instanceof Error ? error.message : "Could not start orchestrator");
} finally {
setIsSpawning(false);
}
};

const restartDaemon = async () => {
setIsRestartingDaemon(true);
setActionError(null);
try {
await startDaemon();
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
} catch (error) {
setActionError(error instanceof Error ? error.message : "Could not restart AO daemon");
} finally {
setIsRestartingDaemon(false);
}
};

const handleTaskCreated = async (sessionId: string) => {
if (!projectId) return;
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
Expand All @@ -126,10 +152,25 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {

const actions = projectId ? (
<>
{!daemonReady ? (
<button
aria-label="Restart daemon"
className="dashboard-app-header__accent-btn"
disabled={isRestartingDaemon}
onClick={() => void restartDaemon()}
type="button"
>
{isRestartingDaemon ? "Restarting..." : "Restart daemon"}
</button>
) : null}
<button
aria-label="New task"
className="dashboard-app-header__accent-btn"
onClick={() => setIsNewTaskOpen(true)}
disabled={!daemonReady}
onClick={() => {
setActionError(null);
setIsNewTaskOpen(true);
}}
type="button"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
Expand All @@ -138,7 +179,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
<button
aria-label={orchestrator ? "Orchestrator" : "Spawn Orchestrator"}
className="dashboard-app-header__primary-btn"
disabled={isSpawning}
disabled={isSpawning || !daemonReady}
onClick={() => void openOrchestrator()}
type="button"
>
Expand All @@ -155,6 +196,17 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
subtitle="Live agent sessions flowing from work → review → merge."
actions={actions}
/>
{projectId && (!daemonReady || actionError) ? (
<div className="mx-[18px] mt-3 rounded-md border border-border bg-surface px-3 py-2 text-[12px] text-muted-foreground">
{actionError ? (
<p role="alert" className="text-destructive">
{actionError}
</p>
) : (
<p>AO daemon is stopped. Restart it to create tasks or start an orchestrator.</p>
)}
</div>
) : null}

<div className="min-h-0 flex-1 overflow-hidden p-[18px]">
{workspaceQuery.isError ? (
Expand Down
41 changes: 39 additions & 2 deletions frontend/src/renderer/components/ShellTopbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { startDaemon } from "../lib/daemon-status";
import { useShell } from "../lib/shell-context";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { useUiStore } from "../stores/ui-store";
Expand Down Expand Up @@ -52,11 +54,14 @@ const STATUS_PILL: Record<WorkerDisplayStatus, { label: string; tone: string; br
export function ShellTopbar() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { daemonStatus } = useShell();
const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string };
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector);
const [isSpawning, setIsSpawning] = useState(false);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
const daemonReady = daemonStatus.state === "ready";
const all = useWorkspaceQuery().data ?? [];

const session = params.sessionId
Expand All @@ -79,7 +84,7 @@ export function ShellTopbar() {
projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" });

const openNewTask = () => {
if (!projectId) return;
if (!projectId || !daemonReady) return;
setIsNewTaskOpen(true);
};

Expand All @@ -94,6 +99,7 @@ export function ShellTopbar() {

const openOrchestrator = async () => {
if (!projectId) return;
if (!daemonReady) return;
void addRendererExceptionStep("Orchestrator open requested", {
source: "orchestrator-open",
operation: "open_orchestrator",
Expand Down Expand Up @@ -129,6 +135,24 @@ export function ShellTopbar() {
}
};

const restartDaemon = async () => {
setIsRestartingDaemon(true);
try {
await startDaemon();
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
} catch (error) {
void captureRendererException(error, {
source: "daemon-restart",
operation: "restart_daemon",
surface: isSessionRoute ? "session_detail" : "project_board",
project_id: projectId,
});
console.error("Failed to restart daemon:", error);
} finally {
setIsRestartingDaemon(false);
}
};

return (
<header className={cn("dashboard-app-header", isMac && "is-under-titlebar-nav")} style={dragStyle}>
<div className="session-topbar__lead">
Expand Down Expand Up @@ -164,13 +188,26 @@ export function ShellTopbar() {

<div className="dashboard-app-header__actions">
<NotificationCenter style={noDragStyle} />
{projectId && !daemonReady ? (
<button
aria-label="Restart daemon"
className="dashboard-app-header__accent-btn"
disabled={isRestartingDaemon}
onClick={() => void restartDaemon()}
style={noDragStyle}
type="button"
>
{isRestartingDaemon ? "Restarting..." : "Restart daemon"}
</button>
) : null}
{isSessionRoute ? (
<>
{isOrchestrator ? (
<>
<button
aria-label="New task"
className="dashboard-app-header__primary-btn"
disabled={!daemonReady}
onClick={openNewTask}
style={noDragStyle}
type="button"
Expand All @@ -197,7 +234,7 @@ export function ShellTopbar() {
<button
aria-label="Open orchestrator"
className="dashboard-app-header__primary-btn dashboard-app-header__primary-btn--compact"
disabled={isSpawning}
disabled={isSpawning || !daemonReady}
onClick={() => void openOrchestrator()}
style={noDragStyle}
type="button"
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/renderer/lib/daemon-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export async function refreshDaemonStatus(): Promise<DaemonStatus> {
return nextStatus;
}

export async function startDaemon(): Promise<DaemonStatus> {
const nextStatus = await aoBridge.daemon.start();
applyDaemonStatus(nextStatus);
return nextStatus;
}

export function readDaemonStatus(): Promise<DaemonStatus> {
return aoBridge.daemon.getStatus();
}
Loading