diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx index 405a97ff..62672801 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -48,24 +48,6 @@ const reviewSession = { pullRequest: { number: 3, state: "open" }, } satisfies WorkspaceSession; -function renderInspector( - session: WorkspaceSession = worker, - onOpenReviewerTerminal?: Parameters[0]["onOpenReviewerTerminal"], -) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - render( - - - , - ); - return queryClient; -} - function renderWithQuery(children: ReactNode) { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -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(); - }); -}); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 0d3391cf..74623b8c 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -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"; @@ -289,70 +289,6 @@ function SummaryView({ - - {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}
); } diff --git a/frontend/src/renderer/components/ShellTopbar.test.tsx b/frontend/src/renderer/components/ShellTopbar.test.tsx new file mode 100644 index 00000000..64c9ddba --- /dev/null +++ b/frontend/src/renderer/components/ShellTopbar.test.tsx @@ -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( + + + , + ); + 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(); + }); +}); diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index 63539287..10f75070 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -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"; @@ -152,6 +154,9 @@ export function ShellTopbar() { {isSpawning ? "Spawning…" : "Open orchestrator"} )} + {/* Kill control sits beside the orchestrator link for active workers — + moved here from the inspector's Summary "Danger zone". */} + {!isOrchestrator && session && sessionIsActive(session) ? : null} {/* Inspector collapse (worker sessions only — orchestrators have no rail). */} {!isOrchestrator && ( + + {error ? ( + + {error} + + ) : null} + + ); + } + + return ( + + ); +} + // 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 }) { diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css index 5032a43a..7b154512 100644 --- a/frontend/src/renderer/styles.css +++ b/frontend/src/renderer/styles.css @@ -345,6 +345,74 @@ body.is-resizing-x [data-slot="sidebar-container"] { color: var(--fg); } +.dashboard-app-header__kill-btn { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 7px; + color: var(--red); + transition: + background 0.12s ease, + color 0.12s ease; +} + +.dashboard-app-header__kill-btn:hover { + background: color-mix(in srgb, var(--red) 12%, transparent); +} + +.dashboard-app-header__kill-confirm { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.dashboard-app-header__kill-confirm-btn { + display: inline-flex; + height: 34px; + align-items: center; + gap: 6px; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--red) 40%, transparent); + padding: 0 12px; + font-size: 13px; + font-weight: 600; + line-height: 1; + color: var(--red); + background: color-mix(in srgb, var(--red) 10%, transparent); + transition: background 0.12s ease; +} + +.dashboard-app-header__kill-confirm-btn:hover { + background: color-mix(in srgb, var(--red) 16%, transparent); +} + +.dashboard-app-header__kill-confirm-btn:disabled { + opacity: 0.6; +} + +.dashboard-app-header__kill-cancel-btn { + display: inline-flex; + height: 34px; + align-items: center; + border-radius: 7px; + padding: 0 10px; + font-size: 13px; + font-weight: 600; + line-height: 1; + color: var(--fg-muted); + transition: color 0.12s ease; +} + +.dashboard-app-header__kill-cancel-btn:hover { + color: var(--fg); +} + +.dashboard-app-header__kill-error { + font-size: 11px; + color: var(--red); +} + .dashboard-app-header__primary-btn { display: inline-flex; height: 34px;