diff --git a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx index fcb274416..664e8c9ce 100644 --- a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx @@ -279,6 +279,14 @@ export function CliSessionWorkSurfaceHeader({ showCacheBadge={showCache} cacheIdleSinceAt={session.chatIdleSinceAt} showGitToolbar + onContextMenu={ + onContextMenu + ? (event) => { + event.preventDefault(); + onContextMenu(session, event); + } + : undefined + } onToggleSessionsPane={onToggleSessionsPane} sessionsPaneCollapsed={sessionsPaneCollapsed} sessionsPaneCount={sessionsPaneCount} diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index a843cc72d..09eef3b07 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -12,6 +12,7 @@ type SessionContextMenuProps = { menu: SessionContextMenuState; onClose: () => void; onStopRuntime: (args: { ptyId: string; sessionId: string }) => void; + onStopAndDelete: (session: TerminalSessionSummary) => void; onDeleteChat: (session: TerminalSessionSummary) => void; onDeleteSession: (session: TerminalSessionSummary) => void; deletingSessionId: string | null; @@ -30,6 +31,7 @@ export function SessionContextMenu({ menu, onClose, onStopRuntime, + onStopAndDelete, onDeleteChat, onDeleteSession, deletingSessionId, @@ -161,13 +163,23 @@ export function SessionContextMenu({ ) : null} + {isRunning && session.ptyId && !isChat ? ( + + ) : null} + {isChat ? ( ) : null} @@ -177,7 +189,7 @@ export function SessionContextMenu({ disabled={deletingSessionId === session.id} onClick={() => { onDeleteSession(session); onClose(); }} > - {deletingSessionId === session.id ? "Deleting..." : "Delete session"} + {deletingSessionId === session.id ? "Deleting…" : "Delete session"} ) : null} diff --git a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx index 3dde20be3..d5d326403 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx @@ -79,6 +79,7 @@ export function SessionInfoPopover({ popover, onClose, onStopRuntime, + onStopAndDelete, onDeleteChat, onDeleteSession, onGoToLane, @@ -88,6 +89,7 @@ export function SessionInfoPopover({ popover: InfoPopoverState; onClose: () => void; onStopRuntime: (args: { ptyId: string; sessionId: string }) => void; + onStopAndDelete: (session: TerminalSessionSummary) => void; onDeleteChat: (session: TerminalSessionSummary) => void; onDeleteSession: (session: TerminalSessionSummary) => void; onGoToLane: (session: TerminalSessionSummary) => void; @@ -290,6 +292,19 @@ export function SessionInfoPopover({ ) : null} + {session.status === "running" && session.ptyId && !isChat ? ( + + + + ) : null} {isChat ? ( ) : null} + {selectedRunningCount > 0 ? ( + + + + ) : null} + + + + ))} + ); }, @@ -252,7 +264,18 @@ vi.mock("./WorkSidebar", () => ({ })); vi.mock("./SessionContextMenu", () => ({ - SessionContextMenu: () => null, + SessionContextMenu: (props: { + menu: { session: TerminalSessionSummary } | null; + onStopAndDelete: (session: TerminalSessionSummary) => void; + }) => { + if (!props.menu) return null; + const session = props.menu.session; + return ( + + ); + }, })); vi.mock("./SessionInfoPopover", () => ({ @@ -516,4 +539,129 @@ describe("TerminalsPage chat session activation", () => { expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining("Delete 2 selected sessions?")); confirmSpy.mockRestore(); }); + + it("stops and deletes a single running CLI session via the context menu", async () => { + const runningCli = workMocks.makeTerminalSession("cli-single", "lane-primary", "codex"); + const sessionDelete = vi.fn().mockResolvedValue(undefined); + const agentChatDelete = vi.fn().mockResolvedValue(undefined); + + Object.defineProperty(window, "ade", { + configurable: true, + value: { + agentChat: { delete: agentChatDelete }, + builtInBrowser: { onEvent: vi.fn(() => vi.fn()) }, + sessions: { delete: sessionDelete }, + }, + }); + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [runningCli], + visibleSessions: [runningCli], + runningFiltered: [runningCli], + runningSessions: [runningCli], + filtered: [runningCli], + sessionsGroupedByLane: new Map([["lane-primary", [runningCli]]]), + closingPtyIds: new Set(), + }; + + render(); + + // Open the context menu for the session, then trigger stop-and-delete. + fireEvent.click(await screen.findByRole("button", { name: "context menu cli-single" })); + fireEvent.click(await screen.findByRole("button", { name: "context stop and delete cli-single" })); + + // The styled confirmation dialog must gate the destructive single-session action. + fireEvent.click(await screen.findByRole("button", { name: "Stop & delete" })); + + await waitFor(() => { + // The session-delete service stops the runtime and removes the record in one call; + // the chat-delete path must not be touched for a non-chat session. + expect(sessionDelete).toHaveBeenCalledWith({ sessionId: "cli-single" }); + expect(workMocks.currentWork.removeSessionFromList).toHaveBeenCalledWith("cli-single"); + expect(workMocks.currentWork.closeTab).toHaveBeenCalledWith("cli-single"); + }); + expect(agentChatDelete).not.toHaveBeenCalled(); + }); + + it("does not delete when the stop-and-delete confirmation is dismissed", async () => { + const runningCli = workMocks.makeTerminalSession("cli-cancel", "lane-primary", "codex"); + const sessionDelete = vi.fn().mockResolvedValue(undefined); + + Object.defineProperty(window, "ade", { + configurable: true, + value: { + agentChat: { delete: vi.fn() }, + builtInBrowser: { onEvent: vi.fn(() => vi.fn()) }, + sessions: { delete: sessionDelete }, + }, + }); + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [runningCli], + visibleSessions: [runningCli], + runningFiltered: [runningCli], + runningSessions: [runningCli], + filtered: [runningCli], + sessionsGroupedByLane: new Map([["lane-primary", [runningCli]]]), + closingPtyIds: new Set(), + }; + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "context menu cli-cancel" })); + fireEvent.click(await screen.findByRole("button", { name: "context stop and delete cli-cancel" })); + fireEvent.click(await screen.findByRole("button", { name: "CANCEL" })); + + await waitFor(() => + expect(screen.queryByRole("button", { name: "Stop & delete" })).toBeNull(), + ); + expect(sessionDelete).not.toHaveBeenCalled(); + expect(workMocks.currentWork.removeSessionFromList).not.toHaveBeenCalled(); + }); + + it("stops and deletes a mixed selection of running CLI and chat sessions", async () => { + const runningCli = workMocks.makeTerminalSession("cli-running", "lane-primary", "codex"); + const runningChat = workMocks.makeTerminalSession("chat-running", "lane-primary", "codex-chat", { + ptyId: null, + }); + const agentChatDelete = vi.fn().mockResolvedValue(undefined); + const sessionDelete = vi.fn().mockResolvedValue(undefined); + + Object.defineProperty(window, "ade", { + configurable: true, + value: { + agentChat: { delete: agentChatDelete }, + builtInBrowser: { onEvent: vi.fn(() => vi.fn()) }, + sessions: { delete: sessionDelete }, + }, + }); + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [runningCli, runningChat], + visibleSessions: [runningCli, runningChat], + runningFiltered: [runningCli, runningChat], + runningSessions: [runningCli, runningChat], + filtered: [runningCli, runningChat], + sessionsGroupedByLane: new Map([["lane-primary", [runningCli, runningChat]]]), + closingPtyIds: new Set(), + }; + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "select cli-running" }), { metaKey: true }); + fireEvent.click(await screen.findByRole("button", { name: "select chat-running" }), { metaKey: true }); + fireEvent.click(await screen.findByRole("button", { name: "bulk stop and delete" })); + + // The styled confirmation dialog gates the destructive action. + fireEvent.click(await screen.findByRole("button", { name: "Stop & delete" })); + + await waitFor(() => { + // The running CLI session is stopped+deleted via the session-delete service, + // and the chat is removed via the chat delete flow — both in one action. + expect(sessionDelete).toHaveBeenCalledWith({ sessionId: "cli-running" }); + expect(agentChatDelete).toHaveBeenCalledWith({ sessionId: "chat-running" }); + expect(workMocks.currentWork.removeSessionFromList).toHaveBeenCalledWith("cli-running"); + expect(workMocks.currentWork.removeSessionFromList).toHaveBeenCalledWith("chat-running"); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index c14e0ae6e..dbeb67932 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -7,6 +7,7 @@ import { WorkViewArea } from "./WorkViewArea"; import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; import { SessionContextMenu, type SessionContextMenuState } from "./SessionContextMenu"; import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; +import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; import { buildDeeplink } from "../../../shared/deeplinks"; import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; @@ -117,6 +118,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const [deletingSessionId, setDeletingSessionId] = useState(null); const [selectedSessionIds, setSelectedSessionIds] = useState>(new Set()); const [selectionAnchorId, setSelectionAnchorId] = useState(null); + const stopAndDeleteConfirm = useConfirmDialog(); const workContentPaneRef = useRef(null); const workSidebarPaneRef = useRef(null); const unifiedChromeRef = useRef(null); @@ -324,6 +326,45 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { [work], ); + const handleStopAndDeleteSession = useCallback( + (session: TerminalSessionSummary) => { + void (async () => { + const label = (session.goal ?? session.title).trim() || "this session"; + const confirmed = await stopAndDeleteConfirm.confirmAsync({ + title: "Stop and delete session", + message: `Stop the runtime for "${label}" and permanently delete it?\n\nThis terminates the running process and removes the saved session from ADE.`, + confirmLabel: "Stop & delete", + danger: true, + }); + if (!confirmed) return; + + setSessionActionError(null); + setDeletingSessionId(session.id); + // The session-delete service stops a running runtime before removing the + // record, so a single call covers both steps for CLI and shell sessions. + try { + await window.ade.sessions.delete({ sessionId: session.id }); + invalidateSessionListCache(); + work.removeSessionFromList(session.id); + work.closeTab(session.id); + setContextMenu((current) => (current?.session.id === session.id ? null : current)); + setInfoPopover((current) => (current?.session.id === session.id ? null : current)); + await work.refresh({ showLoading: false, force: true }).catch((refreshErr: unknown) => { + console.error("[TerminalsPage] refresh after stop-and-delete failed", { sessionId: session.id, refreshErr }); + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error("[TerminalsPage] stop-and-delete session failed", { sessionId: session.id, err }); + setSessionActionError(`Stop and delete failed: ${message}`); + window.setTimeout(() => setSessionActionError(null), 6000); + } finally { + setDeletingSessionId((current) => (current === session.id ? null : current)); + } + })(); + }, + [stopAndDeleteConfirm, work], + ); + const selectedSessions = useMemo( () => selectableSessions.filter((session) => selectedSessionIds.has(session.id)), [selectableSessions, selectedSessionIds], @@ -417,6 +458,66 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }); }, [selectedSessions, work]); + const handleBulkStopAndDeleteSelected = useCallback(() => { + void (async () => { + // Operates on the entire selection: chats delete directly, while running + // CLI/shell sessions are stopped and then deleted by the session-delete + // service. This is what makes a mixed selection deletable in one action. + const targets = selectedSessions; + if (!targets.length) return; + const runningCount = targets.filter(canBulkStopSession).length; + const confirmed = await stopAndDeleteConfirm.confirmAsync({ + title: "Stop and delete sessions", + message: `Stop ${runningCount} running runtime${runningCount === 1 ? "" : "s"} and permanently delete ${targets.length} selected session${targets.length === 1 ? "" : "s"}?\n\nThis terminates running CLI and shell processes, then removes every selected session from ADE.`, + confirmLabel: "Stop & delete", + danger: true, + }); + if (!confirmed) return; + + setSessionActionError(null); + setDeletingSessionId("bulk"); + try { + const results = await allSettledWithConcurrency( + targets, + BULK_SESSION_DELETE_CONCURRENCY, + async (session) => { + if (isChatToolType(session.toolType)) { + await window.ade.agentChat.delete({ sessionId: session.id }); + return; + } + await window.ade.sessions.delete({ sessionId: session.id }); + }, + ); + const failed = results.filter((result) => result.status === "rejected").length; + const succeededIds = targets + .filter((_, index) => results[index]?.status === "fulfilled") + .map((session) => session.id); + for (const sessionId of succeededIds) { + work.removeSessionFromList(sessionId); + work.closeTab(sessionId); + } + setSelectedSessionIds(new Set()); + setSelectionAnchorId(null); + setContextMenu(null); + setInfoPopover(null); + invalidateSessionListCache(); + await work.refresh({ showLoading: false, force: true }).catch((refreshErr: unknown) => { + console.error("[TerminalsPage] refresh after bulk stop-and-delete failed", { refreshErr }); + }); + if (failed > 0) { + setSessionActionError(`Stop and delete failed for ${failed} selected session${failed === 1 ? "" : "s"}.`); + window.setTimeout(() => setSessionActionError(null), 6000); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setSessionActionError(`Stop and delete failed: ${message}`); + window.setTimeout(() => setSessionActionError(null), 6000); + } finally { + setDeletingSessionId((current) => (current === "bulk" ? null : current)); + } + })(); + }, [selectedSessions, stopAndDeleteConfirm, work]); + const handleContinueCliSession = useCallback( async (session: TerminalSessionSummary, text: string) => { setSessionActionError(null); @@ -895,6 +996,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }} onBulkClose={handleBulkCloseSelected} onBulkDelete={handleBulkDeleteSelected} + onBulkStopAndDelete={handleBulkStopAndDeleteSelected} onContextMenu={handleContextMenu} sessionListOrganization={work.sessionListOrganization} setSessionListOrganization={work.setSessionListOrganization} @@ -926,6 +1028,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { selectedSessionIds, handleBulkCloseSelected, handleBulkDeleteSelected, + handleBulkStopAndDeleteSelected, handleInfoClick, handleContextMenu, workViewWithSidebar, @@ -964,6 +1067,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { menu={contextMenu} onClose={() => setContextMenu(null)} onStopRuntime={({ ptyId, sessionId }) => work.stopRuntime(ptyId, sessionId).catch(() => {})} + onStopAndDelete={handleStopAndDeleteSession} onDeleteChat={handleDeleteChat} onDeleteSession={handleDeleteSession} deletingSessionId={deletingSessionId} @@ -1005,12 +1109,15 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { popover={infoPopover} onClose={() => setInfoPopover(null)} onStopRuntime={({ ptyId, sessionId }) => work.stopRuntime(ptyId, sessionId).catch(() => {})} + onStopAndDelete={handleStopAndDeleteSession} onDeleteChat={handleDeleteChat} onDeleteSession={handleDeleteSession} onGoToLane={handleGoToLane} closingPtyIds={work.closingPtyIds} deletingSessionId={deletingSessionId} /> + + ); } diff --git a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx index 6b5be03f5..b4825529d 100644 --- a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; import { SidebarSimple } from "@phosphor-icons/react"; import { ChatGitToolbar } from "../chat/ChatGitToolbar"; import { LaneChip } from "../terminals/LaneChip"; @@ -141,6 +141,12 @@ export type WorkSurfaceHeaderProps = { onToggleToolsPane?: () => void; toolsPaneOpen?: boolean; className?: string; + /** + * Right-click handler for the whole header row. CLI session surfaces wire this + * to open the session context menu (parity with right-clicking a sidebar card); + * chat surfaces omit it. + */ + onContextMenu?: (event: ReactMouseEvent) => void; /** data-testid for integration tests. */ testId?: string; }; @@ -170,6 +176,7 @@ export function WorkSurfaceHeader({ onToggleToolsPane, toolsPaneOpen = false, className, + onContextMenu, testId, }: WorkSurfaceHeaderProps) { // When this header is the title row of a grid tile (FloatingPane with hidden @@ -178,7 +185,7 @@ export function WorkSurfaceHeader({ const embeddedChrome = useFloatingPaneEmbeddedChrome(); const tileDragProps = embeddedChrome?.dragHandleProps ?? null; return ( -
+
{onToggleSessionsPane ? (