From b98ce6b16f9de339a500823f8b90843b620ca407 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Wed, 17 Jun 2026 22:13:12 +0530 Subject: [PATCH 1/2] feat(frontend): move kill session control to topbar beside Open orchestrator Relocate the worker "Kill session" control out of the inspector's Summary "Danger zone" section and into the app topbar actions row, as a small danger-tinted icon button next to "Open orchestrator". It only renders for active worker sessions and keeps the same arm-then-confirm flow and POST /sessions/{id}/kill behavior. Move the kill-button tests to ShellTopbar.test.tsx accordingly. Co-Authored-By: Claude Opus 4.8 --- .../components/SessionInspector.test.tsx | 59 ------------ .../renderer/components/SessionInspector.tsx | 66 +------------- .../renderer/components/ShellTopbar.test.tsx | 91 +++++++++++++++++++ .../src/renderer/components/ShellTopbar.tsx | 82 ++++++++++++++++- frontend/src/renderer/styles.css | 68 ++++++++++++++ 5 files changed, 240 insertions(+), 126 deletions(-) create mode 100644 frontend/src/renderer/components/ShellTopbar.test.tsx 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..82024969 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,11 @@ 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; From 8ff696a06524a78e08e0cad530ed2c67f9477ac1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 16:43:48 +0000 Subject: [PATCH 2/2] chore: format with prettier [skip ci] --- frontend/src/renderer/components/ShellTopbar.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index 82024969..10f75070 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -156,9 +156,7 @@ export function ShellTopbar() { )} {/* Kill control sits beside the orchestrator link for active workers — moved here from the inspector's Summary "Danger zone". */} - {!isOrchestrator && session && sessionIsActive(session) ? ( - - ) : null} + {!isOrchestrator && session && sessionIsActive(session) ? : null} {/* Inspector collapse (worker sessions only — orchestrators have no rail). */} {!isOrchestrator && (