Skip to content
Merged
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
59 changes: 0 additions & 59 deletions frontend/src/renderer/components/SessionInspector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,6 @@ const reviewSession = {
pullRequest: { number: 3, state: "open" },
} satisfies WorkspaceSession;

function renderInspector(
session: WorkspaceSession = worker,
onOpenReviewerTerminal?: Parameters<typeof SessionInspector>[0]["onOpenReviewerTerminal"],
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
render(
<QueryClientProvider client={queryClient}>
<SessionInspector onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} />
</QueryClientProvider>,
);
return queryClient;
}

function renderWithQuery(children: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
Expand Down Expand Up @@ -196,44 +178,3 @@ describe("SessionInspector reviews", () => {
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });
});
});

describe("SessionInspector kill button", () => {
it("arms a confirmation before killing an active session", async () => {
renderInspector();

await userEvent.click(screen.getByRole("button", { name: "Kill session" }));
expect(postMock).not.toHaveBeenCalled();

await userEvent.click(screen.getByRole("button", { name: "Confirm kill" }));

await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/kill", {
params: { path: { sessionId: "sess-1" } },
});
});

it("can back out of the confirmation without killing", async () => {
renderInspector();

await userEvent.click(screen.getByRole("button", { name: "Kill session" }));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));

expect(screen.getByRole("button", { name: "Kill session" })).toBeInTheDocument();
expect(postMock).not.toHaveBeenCalled();
});

it("surfaces the daemon error when the kill fails", async () => {
postMock.mockResolvedValue({ data: undefined, error: { message: "session not found" } });
renderInspector();

await userEvent.click(screen.getByRole("button", { name: "Kill session" }));
await userEvent.click(screen.getByRole("button", { name: "Confirm kill" }));

expect(await screen.findByText("session not found")).toBeInTheDocument();
});

it("hides the kill button for terminated sessions", () => {
renderInspector({ ...worker, status: "terminated" });
expect(screen.queryByRole("button", { name: "Kill session" })).not.toBeInTheDocument();
});
});
66 changes: 1 addition & 65 deletions frontend/src/renderer/components/SessionInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { formatTimeCompact } from "../lib/format-time";
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
import { sessionIsActive, workerDisplayStatus } from "../types/workspace";
import { workerDisplayStatus } from "../types/workspace";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
Expand Down Expand Up @@ -289,70 +289,6 @@ function SummaryView({
<Row k="Session" v={session.id} mono />
</dl>
</Section>

{sessionIsActive(session) ? (
<Section title="Danger zone">
<KillSessionButton session={session} />
</Section>
) : null}
</div>
);
}

// Stop a running worker and tear down its runtime/workspace. Kill is
// irreversible from the UI, so the button arms a one-step confirmation before
// firing POST /sessions/{id}/kill, then invalidates the workspace query so the
// session drops into the board's terminated group.
function KillSessionButton({ session }: { session: WorkspaceSession }) {
const queryClient = useQueryClient();
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);

const kill = useMutation({
mutationFn: async () => {
const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
params: { path: { sessionId: session.id } },
});
if (apiError) throw new Error(apiErrorMessage(apiError));
},
onSuccess: () => {
setConfirming(false);
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
},
onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"),
});

return (
<div className="flex flex-col gap-2">
{confirming ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
className="flex-1 border-error/40 text-error hover:bg-error/10 hover:text-error"
disabled={kill.isPending}
onClick={() => kill.mutate()}
>
<Square aria-hidden="true" />
{kill.isPending ? "Killing…" : "Confirm kill"}
</Button>
<Button variant="ghost" disabled={kill.isPending} onClick={() => setConfirming(false)}>
Cancel
</Button>
</div>
) : (
<Button
variant="outline"
className="w-full border-error/40 text-error hover:bg-error/10 hover:text-error"
onClick={() => {
setError(null);
setConfirming(true);
}}
>
<Square aria-hidden="true" />
Kill session
</Button>
)}
{error ? <p className="text-[11px] text-error">{error}</p> : null}
</div>
);
}
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/renderer/components/ShellTopbar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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 type { WorkspaceSession } from "../types/workspace";
import { TopbarKillButton } from "./ShellTopbar";

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

vi.mock("../lib/api-client", () => ({
apiClient: {
POST: postMock,
},
apiErrorMessage: (error: unknown, fallback = "Request failed") => {
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error) {
return String((error as { message: unknown }).message);
}
return fallback;
},
}));

const worker: WorkspaceSession = {
id: "sess-1",
workspaceId: "proj-1",
workspaceName: "my-app",
title: "do the thing",
provider: "claude-code",
kind: "worker",
branch: "ao/sess-1",
status: "working",
updatedAt: "2026-06-10T00:00:00Z",
};

function renderKill(session: WorkspaceSession = worker) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
render(
<QueryClientProvider client={queryClient}>
<TopbarKillButton session={session} />
</QueryClientProvider>,
);
return queryClient;
}

beforeEach(() => {
postMock.mockReset();
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
});

describe("TopbarKillButton", () => {
it("arms a confirmation before killing an active session", async () => {
renderKill();

await userEvent.click(screen.getByRole("button", { name: "Kill session" }));
expect(postMock).not.toHaveBeenCalled();

await userEvent.click(screen.getByRole("button", { name: "Confirm kill" }));

await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/kill", {
params: { path: { sessionId: "sess-1" } },
});
});

it("can back out of the confirmation without killing", async () => {
renderKill();

await userEvent.click(screen.getByRole("button", { name: "Kill session" }));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));

expect(screen.getByRole("button", { name: "Kill session" })).toBeInTheDocument();
expect(postMock).not.toHaveBeenCalled();
});

it("surfaces the daemon error when the kill fails", async () => {
postMock.mockResolvedValue({ data: undefined, error: { message: "session not found" } });
renderKill();

await userEvent.click(screen.getByRole("button", { name: "Kill session" }));
await userEvent.click(screen.getByRole("button", { name: "Confirm kill" }));

expect(await screen.findByText("session not found")).toBeInTheDocument();
});
});
80 changes: 78 additions & 2 deletions frontend/src/renderer/components/ShellTopbar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { GitBranch, LayoutGrid, PanelRightClose, PanelRightOpen, Waypoints } from "lucide-react";
import { GitBranch, LayoutGrid, PanelRightClose, PanelRightOpen, Square, Waypoints } from "lucide-react";
import { useState } from "react";
import {
findProjectOrchestrator,
isOrchestratorSession,
sessionIsActive,
workerDisplayStatus,
type WorkerDisplayStatus,
type WorkspaceSession,
} from "../types/workspace";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { useUiStore } from "../stores/ui-store";
import { cn } from "../lib/utils";
Expand Down Expand Up @@ -152,6 +154,9 @@ export function ShellTopbar() {
{isSpawning ? "Spawning…" : "Open orchestrator"}
</button>
)}
{/* Kill control sits beside the orchestrator link for active workers —
moved here from the inspector's Summary "Danger zone". */}
{!isOrchestrator && session && sessionIsActive(session) ? <TopbarKillButton session={session} /> : null}
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */}
{!isOrchestrator && (
<button
Expand Down Expand Up @@ -207,6 +212,77 @@ export function ShellTopbar() {
);
}

// Compact kill control for the topbar actions row. Stop a running worker and
// tear down its runtime/workspace. Kill is irreversible from the UI, so the
// button arms a one-step confirmation before firing POST /sessions/{id}/kill,
// then invalidates the workspace query so the session drops into the board's
// terminated group.
export function TopbarKillButton({ session }: { session: WorkspaceSession }) {
const queryClient = useQueryClient();
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);

const kill = useMutation({
mutationFn: async () => {
const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
params: { path: { sessionId: session.id } },
});
if (apiError) throw new Error(apiErrorMessage(apiError));
},
onSuccess: () => {
setConfirming(false);
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
},
onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"),
});

if (confirming) {
return (
<div className="dashboard-app-header__kill-confirm" style={noDragStyle}>
<button
aria-label="Confirm kill"
className="dashboard-app-header__kill-confirm-btn"
disabled={kill.isPending}
onClick={() => kill.mutate()}
type="button"
>
<Square className="h-3.5 w-3.5" aria-hidden="true" />
{kill.isPending ? "Killing…" : "Confirm kill"}
</button>
<button
className="dashboard-app-header__kill-cancel-btn"
disabled={kill.isPending}
onClick={() => setConfirming(false)}
type="button"
>
Cancel
</button>
{error ? (
<span className="dashboard-app-header__kill-error" role="alert">
{error}
</span>
) : null}
</div>
);
}

return (
<button
aria-label="Kill session"
className="dashboard-app-header__kill-btn"
onClick={() => {
setError(null);
setConfirming(true);
}}
style={noDragStyle}
title="Kill session"
type="button"
>
<Square className="h-[15px] w-[15px]" aria-hidden="true" />
</button>
);
}

// StatusBadge --pill: tinted bordered pill (inset 25%-tone hairline + 7%-tone
// fill) with a 6px dot that breathes while the agent is working.
function SessionStatusPill({ session }: { session: WorkspaceSession }) {
Expand Down
Loading