diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx new file mode 100644 index 00000000..e256a000 --- /dev/null +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -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( + + + , + ); + 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(); + }); +}); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index dee4cf69..6356efdf 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -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 }) { + + {sessionIsActive(session) ? ( +
+ +
+ ) : null} + + ); +} + +// 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(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 ( +
+ {confirming ? ( +
+ + +
+ ) : ( + + )} + {error ?

{error}

: null}
); }