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:
parent
6ac65d2cd3
commit
4bbcf9e925
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 { useState, type ReactNode } from "react";
|
||||||
import { GitBranch, GitCommitHorizontal, GitPullRequest, Plus, Square, Trash2 } from "lucide-react";
|
import { GitBranch, GitCommitHorizontal, GitPullRequest, Plus, Square, Trash2 } from "lucide-react";
|
||||||
import type { components } from "../../api/schema";
|
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 { formatTimeCompact } from "../lib/format-time";
|
||||||
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
|
import type { SessionStatus, WorkspaceSession } from "../types/workspace";
|
||||||
import { workerDisplayStatus } from "../types/workspace";
|
import { sessionIsActive, workerDisplayStatus } from "../types/workspace";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -190,6 +191,70 @@ function SummaryView({ session }: { session: WorkspaceSession }) {
|
||||||
<Row k="Session" v={session.id} mono />
|
<Row k="Session" v={session.id} mono />
|
||||||
</dl>
|
</dl>
|
||||||
</Section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue