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
101 changes: 101 additions & 0 deletions frontend/src/renderer/components/SessionInspector.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
71 changes: 68 additions & 3 deletions frontend/src/renderer/components/SessionInspector.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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>
);
}
Expand Down
Loading