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
52 changes: 52 additions & 0 deletions src/main/core/pty/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Comment thread
janburzinski marked this conversation as resolved.

// 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-
Expand Down
127 changes: 123 additions & 4 deletions src/renderer/features/command-palette/resource-monitor-view.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -187,13 +192,127 @@ function AgentRow({ entry }: { entry: Entry }) {
<span className="truncate text-xs text-foreground-muted">{label}</span>
{entry.pid === undefined ? <Badge>SSH</Badge> : null}
</div>
<span className="shrink-0 text-xs text-foreground/50 tabular-nums">
{norm.toFixed(0)}% · {formatBytes(entry.memory)}
</span>
<div className="relative flex shrink-0 items-center">
<span
className={cn(
'text-xs text-foreground/50 tabular-nums transition-opacity group-hover/agent:opacity-0',
armed && 'opacity-0'
)}
>
{norm.toFixed(0)}% · {formatBytes(entry.memory)}
</span>
<StopButton
sessionId={entry.sessionId}
label={label}
armed={armed}
onArmedChange={setArmed}
/>
</div>
</div>
);
}

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<number | null>(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);
}
Comment thread
janburzinski marked this conversation as resolved.
},
[armed, stopping, sessionId, clearReset, onArmedChange]
);

if (armed) {
return (
<button
type="button"
disabled={stopping}
onClick={handleClick}
className="absolute top-1/2 right-0 flex h-5 -translate-y-1/2 items-center rounded-md bg-red-500/10 px-1.5 text-[11px] font-medium text-red-500 transition-colors hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Confirm stop ${label}`}
>
Stop?
</button>
);
}

return (
<Tooltip>
<TooltipTrigger>
<button
type="button"
disabled={stopping}
onClick={handleClick}
className="absolute top-1/2 right-0 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-foreground/50 opacity-0 transition-opacity group-hover/agent:opacity-100 hover:bg-red-500/10 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Stop ${label}`}
>
<X size={13} />
</button>
</TooltipTrigger>
<TooltipContent>Stop session</TooltipContent>
</Tooltip>
);
}

function Badge({ children }: { children: ReactNode }) {
return (
<span className="shrink-0 rounded bg-background-2 px-1.5 py-px font-mono text-[9px] tracking-wider text-foreground/50 uppercase">
Expand Down
Loading