diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index 51620abc36..13e278140a 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -81,6 +81,58 @@ export const ptyController = createRPCController({ return ok(); }, + /** + * Stop a session for good without deleting its conversation/terminal record. + * + * Unlike `kill`, which only terminates the OS process, this routes through + * the owning provider so the session is removed from its respawn tracking + * (`knownSessionIds`/`sessions`). A bare `kill` leaves those intact, so the + * provider's `onExit` handler respawns the PTY ~500ms later — which is what + * made killing from the resource monitor appear to do nothing. The tab and + * its history are preserved; the session simply stays stopped until the task + * is remounted or the user starts a new one. + */ + stopSession: async (sessionId: string) => { + const parsed = parsePtySessionId(sessionId); + if (!parsed) return err({ type: 'invalid_session' as const }); + const { scopeId, leafId } = parsed; + + // Agents and terminals are scoped by task id, so the task lookup resolves + // the owning provider. Conversation PTYs carry a providerId in their + // registry metadata; plain terminals do not — that distinguishes the two. + const task = taskManager.getTask(scopeId); + if (task) { + const isConversation = ptySessionRegistry.getMetadata(sessionId)?.providerId !== undefined; + try { + if (isConversation) { + await task.conversations.stopSession(leafId); + } else { + await task.terminals.killTerminal(leafId); + } + } catch (e) { + log.warn('ptyController.stopSession: error stopping task PTY', { + sessionId, + error: String(e), + }); + return err({ type: 'stop_failed' as const, message: String((e as Error)?.message || e) }); + } + return ok(); + } + + // Lifecycle scripts are scoped by workspace id (no task match) and never + // respawn, so a raw kill is sufficient and safe. + const pty = ptySessionRegistry.get(sessionId); + if (pty) { + try { + pty.kill(); + } catch (e) { + log.warn('ptyController.stopSession: error killing PTY', { sessionId, error: String(e) }); + } + } + ptySessionRegistry.unregister(sessionId); + return ok(); + }, + /** * Upload local files into the task's working directory on a remote SSH host * and return their remote paths. Uses the SFTP subsystem of the already- diff --git a/src/renderer/features/command-palette/resource-monitor-view.tsx b/src/renderer/features/command-palette/resource-monitor-view.tsx index 6edb901a15..6cbb8a55c0 100644 --- a/src/renderer/features/command-palette/resource-monitor-view.tsx +++ b/src/renderer/features/command-palette/resource-monitor-view.tsx @@ -1,11 +1,15 @@ -import { Activity, ArrowLeft, Check, Copy, Folder, GitBranch, Terminal } from 'lucide-react'; +import { Activity, ArrowLeft, Check, Copy, Folder, GitBranch, Terminal, X } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { getTaskView } from '@renderer/features/tasks/stores/task-selectors'; import AgentLogo from '@renderer/lib/components/agent-logo'; +import { rpc } from '@renderer/lib/ipc'; import { agentMeta } from '@renderer/lib/providers/meta'; import { appState } from '@renderer/lib/stores/app-state'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; import { formatBytes } from '@renderer/utils/formatBytes'; +import { cn } from '@renderer/utils/utils'; +import { parsePtySessionId } from '@shared/ptySessionId'; import type { ResourceAppProcess, ResourceSnapshot } from '@shared/resource-monitor'; import { appProcessLabel, @@ -160,6 +164,7 @@ function AgentRow({ entry }: { entry: Entry }) { const norm = appState.resourceMonitor.normalizedCpu(entry); const meta = entry.providerId ? agentMeta[entry.providerId] : undefined; const label = entryLabel(entry); + const [armed, setArmed] = useState(false); return (
{label} {entry.pid === undefined ? SSH : null}
- - {norm.toFixed(0)}% · {formatBytes(entry.memory)} - +
+ + {norm.toFixed(0)}% · {formatBytes(entry.memory)} + + +
); } +function closeConversationTabsForSession(sessionId: string): void { + const parsed = parsePtySessionId(sessionId); + if (!parsed) return; + + const taskView = getTaskView(parsed.projectId, parsed.scopeId); + if (!taskView) return; + + for (const { tabManager } of taskView.tabGroupManager.groups) { + for (const [tabId, entry] of tabManager.entries) { + if (entry.kind === 'conversation' && entry.conversationId === parsed.leafId) { + tabManager.closeTab(tabId); + } + } + } +} + +function StopButton({ + sessionId, + label, + armed, + onArmedChange, +}: { + sessionId: string; + label: string; + armed: boolean; + onArmedChange: (armed: boolean) => void; +}) { + const [stopping, setStopping] = useState(false); + const resetRef = useRef(null); + + const clearReset = useCallback(() => { + if (resetRef.current !== null) { + window.clearTimeout(resetRef.current); + resetRef.current = null; + } + }, []); + + useEffect(() => clearReset, [clearReset]); + + const handleClick = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (stopping) return; + if (!armed) { + // First click arms; auto-disarm if the second click doesn't follow. + onArmedChange(true); + clearReset(); + resetRef.current = window.setTimeout(() => { + onArmedChange(false); + resetRef.current = null; + }, 3000); + return; + } + clearReset(); + setStopping(true); + try { + await rpc.pty.stopSession(sessionId); + closeConversationTabsForSession(sessionId); + await appState.resourceMonitor.refresh(); + setStopping(false); + onArmedChange(false); + } catch { + setStopping(false); + onArmedChange(false); + } + }, + [armed, stopping, sessionId, clearReset, onArmedChange] + ); + + if (armed) { + return ( + + ); + } + + return ( + + + + + Stop session + + ); +} + function Badge({ children }: { children: ReactNode }) { return (