feat(frontend): add kill button to worker session page (#288)

The worker session inspector had no way to stop a running session from
the UI. Add a "Kill session" action in the Summary view's Danger zone
that arms a one-step confirmation, then POSTs /sessions/{id}/kill and
invalidates the workspace query so the session moves to the terminated
group. The action is hidden for already-terminated/merged sessions.

Closes #287

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-17 20:50:10 +05:30 committed by GitHub
parent 6ac65d2cd3
commit 4bbcf9e925
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 169 additions and 3 deletions

View File

@ -0,0 +1,101 @@
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";
const { getMock, postMock } = vi.hoisted(() => ({
getMock: vi.fn(),
postMock: vi.fn(),
}));
vi.mock("../lib/api-client", () => ({
apiClient: {
GET: getMock,
POST: postMock,
},
apiErrorMessage: (error: unknown) => {
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error) {
return String((error as { message: unknown }).message);
}
return "Request failed";
},
}));
import { SessionInspector } from "./SessionInspector";
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 renderInspector(session: WorkspaceSession = worker) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
render(
<QueryClientProvider client={queryClient}>
<SessionInspector session={session} />
</QueryClientProvider>,
);
return queryClient;
}
beforeEach(() => {
getMock.mockReset();
postMock.mockReset();
getMock.mockResolvedValue({ data: { prs: [] }, error: undefined });
postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined });
});
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();
});
});

View File

@ -1,11 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { GitBranch, GitCommitHorizontal, GitPullRequest, Plus, Square, Trash2 } from "lucide-react";
import type { components } from "../../api/schema";
import { apiClient } from "../lib/api-client";
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 { workerDisplayStatus } from "../types/workspace";
import { sessionIsActive, workerDisplayStatus } from "../types/workspace";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
@ -190,6 +191,70 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
<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>
);
}