diff --git a/apps/desktop/src/main/services/processes/processService.test.ts b/apps/desktop/src/main/services/processes/processService.test.ts index 063537c8f..5de1a8a87 100644 --- a/apps/desktop/src/main/services/processes/processService.test.ts +++ b/apps/desktop/src/main/services/processes/processService.test.ts @@ -1000,6 +1000,90 @@ describe("processService PTY-backed run commands", () => { } }); + it("writes PTY startup failures into the run transcript", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-start-failure-")); + const dbPath = path.join(tmpDir, "kv.sqlite"); + const projectId = "proj-start-failure"; + const logger = createLogger(); + const db = await openKvDb(dbPath, createLogger()); + const now = "2026-03-24T12:00:00.000Z"; + const sessionStore = new Map(); + const dataListeners = new Set<(event: { laneId: string; ptyId: string; sessionId: string; data: string }) => void>(); + const exitListeners = new Set<(event: { laneId: string; ptyId: string; sessionId: string; exitCode: number | null }) => void>(); + + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, tmpDir, "test", "main", now, now], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ["lane-fail", projectId, "Lane Fail", null, "worktree", "main", "feature/fail", tmpDir, null, 0, null, null, null, null, "active", now, null], + ); + + const config = makeMinimalConfig([ + { id: "bad-command", command: ["./missing-script.sh", "dev"], cwd: "." }, + ]); + const ptyService = { + create: vi.fn(async (args: any) => { + const transcriptPath = path.join(tmpDir, ".ade", "transcripts", `${args.sessionId}.log`); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + sessionStore.set(args.sessionId, { id: args.sessionId, transcriptPath }); + throw new Error("spawn ./missing-script.sh ENOENT"); + }), + dispose: vi.fn(), + onData: vi.fn((listener: (event: { laneId: string; ptyId: string; sessionId: string; data: string }) => void) => { + dataListeners.add(listener); + return () => dataListeners.delete(listener); + }), + onExit: vi.fn((listener: (event: { laneId: string; ptyId: string; sessionId: string; exitCode: number | null }) => void) => { + exitListeners.add(listener); + return () => exitListeners.delete(listener); + }), + } as any; + const service = createProcessService({ + db, + projectId, + logger, + laneService: { + getLaneWorktreePath: () => tmpDir, + list: async () => [makeLaneSummary(tmpDir, "lane-fail")], + } as any, + projectConfigService: { + get: () => config, + getEffective: () => config.effective, + getExecutableConfig: () => config.effective, + } as any, + sessionService: { + get: vi.fn((sessionId: string) => sessionStore.get(sessionId) ?? null), + } as any, + ptyService, + broadcastEvent: () => {}, + }); + + try { + await expect(service.start({ laneId: "lane-fail", processId: "bad-command" })).rejects.toThrow("ENOENT"); + const runtime = service.listRuntime("lane-fail")[0]!; + expect(runtime.status).toBe("crashed"); + const tail = service.getLogTail({ + laneId: "lane-fail", + processId: "bad-command", + runId: runtime.runId, + }); + expect(tail).toContain("failed to start"); + expect(tail).toContain("./missing-script.sh dev"); + expect(tail).toContain(`[ADE] Working directory: ${fs.realpathSync(tmpDir)}`); + expect(tail).toContain("spawn ./missing-script.sh ENOENT"); + } finally { + service.disposeAll(); + db.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("getLogTail({ runId }) returns only the specified run's transcript", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-process-logtail-")); const dbPath = path.join(tmpDir, "kv.sqlite"); diff --git a/apps/desktop/src/main/services/processes/processService.ts b/apps/desktop/src/main/services/processes/processService.ts index 10c67062e..b1eaef438 100644 --- a/apps/desktop/src/main/services/processes/processService.ts +++ b/apps/desktop/src/main/services/processes/processService.ts @@ -536,9 +536,10 @@ export function createProcessService({ const handleStartFailure = (args: { entry: ManagedProcessEntry; startedAt: string; + cwd: string; error: unknown; }) => { - const { entry, startedAt, error } = args; + const { entry, startedAt, cwd, error } = args; const endedAt = nowIso(); if (entry.sessionId) sessionToRunId.delete(entry.sessionId); if (entry.ptyId) ptyToRunId.delete(entry.ptyId); @@ -555,6 +556,25 @@ export function createProcessService({ entry.runtime.exitCode = null; entry.runtime.lastExitCode = null; entry.runtime.logPath = entry.transcriptPath; + if (entry.transcriptPath) { + try { + fs.mkdirSync(path.dirname(entry.transcriptPath), { recursive: true }); + fs.appendFileSync( + entry.transcriptPath, + [ + "", + `[ADE] Process '${entry.definition.name || entry.definition.id}' failed to start.`, + `[ADE] Command: ${entry.definition.command.join(" ")}`, + `[ADE] Working directory: ${cwd}`, + `[ADE] Error: ${error instanceof Error ? error.message : String(error)}`, + "", + ].join("\n"), + "utf8", + ); + } catch { + // Best-effort: the renderer still receives the thrown startup error. + } + } emitRuntime(entry); upsertRunStart(entry.runId, entry.laneId, entry.processId, startedAt, entry.transcriptPath ?? ""); @@ -666,7 +686,7 @@ export function createProcessService({ }); return cloneRuntime(entry.runtime); } catch (error) { - handleStartFailure({ entry, startedAt, error }); + handleStartFailure({ entry, startedAt, cwd, error }); throw error; } }; diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 0b744ad66..2fc4cab97 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -479,6 +479,37 @@ describe("ptyService", () => { expect(mockPty.write).toHaveBeenCalledWith("codex --no-alt-screen \"ADE session guidance\"\r"); }); + it("falls back to a shell exec command when direct command spawn fails before a terminal attaches", async () => { + setPlatform("darwin"); + const { service, mockPty, loadPty } = createHarness(); + const spawn = vi.fn((command: string) => { + if (command === "./scripts/dogfood.sh") throw new Error("ENOENT"); + return mockPty; + }); + loadPty.mockImplementationOnce(() => ({ spawn: spawn as any })); + + await service.create({ + laneId: "lane-1", + title: "Run command", + cols: 80, + rows: 24, + command: "./scripts/dogfood.sh", + args: ["onboarding fixes", "quote's ok"], + }); + + expect(spawn).toHaveBeenCalledWith( + "./scripts/dogfood.sh", + ["onboarding fixes", "quote's ok"], + expect.any(Object), + ); + expect(spawn).toHaveBeenCalledWith( + expect.stringMatching(/(?:zsh|bash|sh)$/), + expect.any(Array), + expect.any(Object), + ); + expect(mockPty.write).toHaveBeenCalledWith("exec ./scripts/dogfood.sh 'onboarding fixes' 'quote'\\''s ok'\r"); + }); + it("wraps direct Windows command shims through cmd.exe", async () => { setPlatform("win32"); const harness = createHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 1e2b6fcc7..af3b5e9cf 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -182,6 +182,17 @@ function resolveShellCandidates(): ShellSpec[] { return uniq.map((file) => ({ file, args: [] })); } +function quotePosixShellArg(value: string): string { + if (!value.length) return "''"; + if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(value)) return value; + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function buildDirectCommandShellFallback(command: string, args: string[]): string | null { + if (process.platform === "win32") return null; + return ["exec", command, ...args].map(quotePosixShellArg).join(" "); +} + function clampDims(cols: number, rows: number): { cols: number; rows: number } { const safeCols = Number.isFinite(cols) ? Math.max(20, Math.min(400, Math.floor(cols))) : 80; const safeRows = Number.isFinite(rows) ? Math.max(6, Math.min(200, Math.floor(rows))) : 24; @@ -1568,6 +1579,8 @@ export function createPtyService({ launchedDirectCommand = true; } catch (err) { lastErr = err; + const shellFallbackCmd = buildDirectCommandShellFallback(directCommand, directArgs); + if (shellFallbackCmd) startupCommand ||= shellFallbackCmd; } } if (!created && (!directCommand || startupCommand)) { diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx index 82bb7644c..391aa18a8 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx @@ -50,6 +50,11 @@ type ChatTerminalDrawerProps = { onToggle: () => void; laneId: string; chatSessionId?: string | null; + autoCreateOnOpen?: boolean; + createRequestNonce?: number; + disposeTabsOnUnmount?: boolean; + emptyMessage?: string; + onCreateError?: (message: string) => void; revealRequest?: { terminalId: string; ptyId: string; @@ -125,6 +130,11 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ onToggle, laneId, chatSessionId, + autoCreateOnOpen = true, + createRequestNonce = 0, + disposeTabsOnUnmount = false, + emptyMessage = "Create a terminal to start working in this chat.", + onCreateError, revealRequest, }: ChatTerminalDrawerProps) { const uiStateKey = drawerStateKey(chatSessionId, laneId); @@ -140,6 +150,9 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ const pendingAutoCreateRef = useRef(false); const tabsRef = useRef([]); const restoringUiStateRef = useRef(false); + const lastHandledCreateRequestRef = useRef(0); + const createRequestHandledThisOpenRef = useRef(false); + const revealHandledThisOpenRef = useRef(false); // revealRequest is edge-triggered (the parent re-uses the same prop slot // across renders). Track the (chatKey, nonce) we've already applied so a // stale request from a previous chat doesn't keep blocking the new chat's @@ -148,6 +161,11 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ tabsRef.current = tabs; + const reportCreateError = useCallback((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + onCreateError?.(message); + }, [onCreateError]); + useEffect(() => { restoringUiStateRef.current = true; previousOpenRef.current = open; @@ -220,10 +238,12 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ setTabs((prev) => [...prev, nextEntry]); setActiveTabId(tabId); + } catch (error) { + reportCreateError(error); } finally { setCreatingTab(false); } - }, [chatSessionId, creatingTab, laneId]); + }, [chatSessionId, creatingTab, laneId, reportCreateError]); useEffect(() => { if (!chatSessionId) return; @@ -278,6 +298,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ ); if (existing) { setActiveTabId(existing.id); + revealHandledThisOpenRef.current = true; return; } const tabId = `chat-term-${revealRequest.terminalId}`; @@ -290,8 +311,17 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ }; setTabs((prev) => [...prev, nextEntry]); setActiveTabId(tabId); + revealHandledThisOpenRef.current = true; }, [revealRequest, uiStateKey]); + useEffect(() => { + if (!open || creatingTab || createRequestNonce <= 0 || lastHandledCreateRequestRef.current === createRequestNonce) return; + lastHandledCreateRequestRef.current = createRequestNonce; + pendingAutoCreateRef.current = false; + createRequestHandledThisOpenRef.current = true; + void createTab(); + }, [createRequestNonce, createTab, creatingTab, open]); + useEffect(() => { const wasOpen = previousOpenRef.current; previousOpenRef.current = open; @@ -302,13 +332,23 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ } if (!wasOpen) pendingAutoCreateRef.current = true; + if (createRequestHandledThisOpenRef.current) { + createRequestHandledThisOpenRef.current = false; + pendingAutoCreateRef.current = false; + return; + } + if (revealHandledThisOpenRef.current) { + revealHandledThisOpenRef.current = false; + pendingAutoCreateRef.current = false; + return; + } // Treat already-consumed reveal requests as null so switching chats with // a stale revealRequest in props doesn't block auto-create on the new // drawer. const last = lastHandledRevealRef.current; const revealActive = revealRequest != null && !(last && last.chatKey === uiStateKey && last.nonce === revealRequest.nonce); - if (revealActive || tabs.length > 0) { + if (!autoCreateOnOpen || revealActive || tabs.length > 0) { pendingAutoCreateRef.current = false; return; } @@ -316,7 +356,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ pendingAutoCreateRef.current = false; void createTab(); - }, [createTab, creatingTab, open, restoringTabs, revealRequest, tabs.length, uiStateKey]); + }, [autoCreateOnOpen, createTab, creatingTab, open, restoringTabs, revealRequest, tabs.length, uiStateKey]); useEffect(() => { if (tabs.length > 0) { @@ -329,7 +369,9 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ }, [creatingTab, onToggle, open, tabs.length]); useEffect(() => { - const unsubscribe = window.ade.pty.onExit((ev: PtyExitEvent) => { + const ptyBridge = window.ade?.pty; + if (!ptyBridge?.onExit) return undefined; + const unsubscribe = ptyBridge.onExit((ev: PtyExitEvent) => { setTabs((prev) => prev.map((tab) => ( tab.ptyId === ev.ptyId ? { ...tab, exited: true } @@ -339,6 +381,13 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ return unsubscribe; }, []); + useEffect(() => () => { + if (!disposeTabsOnUnmount) return; + for (const tab of tabsRef.current) { + window.ade.pty.dispose({ ptyId: tab.ptyId, sessionId: tab.sessionId }).catch(() => {}); + } + }, [disposeTabsOnUnmount]); + // Drop drawer tabs when their session is deleted from the sidebar so the user // can't keep working in a shell whose backing session no longer exists. useEffect(() => { @@ -513,7 +562,7 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ /> ) : (
- Create a terminal to start working in this chat. + {emptyMessage}
)} diff --git a/apps/desktop/src/renderer/components/run/CommandCard.tsx b/apps/desktop/src/renderer/components/run/CommandCard.tsx index 536b557a8..f2d47fce2 100644 --- a/apps/desktop/src/renderer/components/run/CommandCard.tsx +++ b/apps/desktop/src/renderer/components/run/CommandCard.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { DotsThreeVertical, Play, Plus, X } from "@phosphor-icons/react"; +import { DotsThreeVertical, Play, Plus, Terminal, X } from "@phosphor-icons/react"; import type { LaneSummary, ProcessDefinition, ProcessGroupDefinition, ProcessRuntime } from "../../../shared/types"; import { COLORS, MONO_FONT, inlineBadge, outlineButton, processStatusColor } from "../lanes/laneDesignTokens"; import { useClickOutside } from "../../hooks/useClickOutside"; @@ -19,6 +19,7 @@ type CommandCardProps = { onDelete: (processId: string) => void; onAddToGroup?: (processId: string, groupId: string) => void; onKillRuntime?: (runtime: ProcessRuntime) => void; + onOpenRuntime?: (runtime: ProcessRuntime) => void; }; function sortRuntimes(runtimes: ProcessRuntime[]): ProcessRuntime[] { @@ -43,6 +44,7 @@ export function CommandCard({ onDelete, onAddToGroup, onKillRuntime, + onOpenRuntime, }: CommandCardProps) { const hasLanes = lanes.length > 0; const laneId = selectedLaneId && lanes.some((item) => item.id === selectedLaneId) @@ -419,6 +421,21 @@ export function CommandCard({ ))} ) : null} + {onOpenRuntime && latestRuntime.sessionId && latestRuntime.ptyId ? ( + + ) : null} ) : (
No runs on this lane yet.
diff --git a/apps/desktop/src/renderer/components/run/RunPage.advancedDrawer.test.tsx b/apps/desktop/src/renderer/components/run/RunPage.advancedDrawer.test.tsx index a9fba7f1e..28058a665 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.advancedDrawer.test.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.advancedDrawer.test.tsx @@ -23,6 +23,14 @@ vi.mock("./LaneRuntimeBar", () => { }; }); +vi.mock("../terminals/TerminalView", () => { + const ReactMod = require("react") as typeof import("react"); + return { + TerminalView: (props: { sessionId: string; ptyId: string }) => + ReactMod.createElement("div", { "data-testid": "terminal-view" }, `${props.sessionId}:${props.ptyId}`), + }; +}); + const laneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }; const stubLane: LaneSummary = { @@ -73,8 +81,9 @@ function installAdeStub() { stopGroup: vi.fn(), }, pty: { - create: vi.fn(), + create: vi.fn().mockResolvedValue({ sessionId: "terminal-new", ptyId: "pty-new", pid: 1234 }), dispose: vi.fn().mockResolvedValue(undefined), + onExit: vi.fn(() => vi.fn()), }, project: { listRecent: vi.fn().mockResolvedValue([]), @@ -132,4 +141,105 @@ describe("RunPage Advanced lane runtime drawer", () => { expect(toggle.getAttribute("aria-expanded")).toBe("true"); await waitFor(() => expect(mocks.laneBarSpy).toHaveBeenCalled()); }); + + it("uses the shared terminal drawer when opening a run shell", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /new shell/i })); + + await waitFor(() => { + expect(vi.mocked((window as unknown as { ade: { pty: { create: ReturnType } } }).ade.pty.create)).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-a", + toolType: "shell", + tracked: true, + }), + ); + }); + expect((await screen.findByTestId("terminal-view")).textContent).toBe("terminal-new:pty-new"); + }); + + it("opens the terminal drawer without creating a shell from the plain toggle", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /^terminal$/i })); + + expect(await screen.findByText("Open a shell or run a command to attach a terminal.")).toBeTruthy(); + expect(vi.mocked((window as unknown as { ade: { pty: { create: ReturnType } } }).ade.pty.create)).not.toHaveBeenCalled(); + }); + + it("surfaces shell creation failures from the shared terminal drawer", async () => { + const create = vi.mocked((window as unknown as { ade: { pty: { create: ReturnType } } }).ade.pty.create); + create.mockRejectedValueOnce(new Error("missing shell")); + + render(); + fireEvent.click(screen.getByRole("button", { name: /new shell/i })); + + expect(await screen.findByText("missing shell")).toBeTruthy(); + }); + + it("disposes run shell terminals when RunPage unmounts", async () => { + const { unmount } = render(); + fireEvent.click(screen.getByRole("button", { name: /new shell/i })); + expect((await screen.findByTestId("terminal-view")).textContent).toBe("terminal-new:pty-new"); + + unmount(); + + expect(vi.mocked((window as unknown as { ade: { pty: { dispose: ReturnType } } }).ade.pty.dispose)).toHaveBeenCalledWith({ + ptyId: "pty-new", + sessionId: "terminal-new", + }); + }); + + it("reveals a run command terminal returned by the process service", async () => { + const definition = { + id: "proc-1", + name: "Dev server", + command: ["npm", "run", "dev"], + cwd: ".", + env: {}, + groupIds: [], + autostart: false, + restart: "never", + gracefulShutdownMs: 7000, + dependsOn: [], + readiness: { type: "none" as const }, + }; + const runtime = { + runId: "run-1", + laneId: "lane-a", + processId: "proc-1", + status: "running" as const, + readiness: "unknown" as const, + pid: 4321, + sessionId: "terminal-run", + ptyId: "pty-run", + startedAt: "2026-04-30T12:00:00.000Z", + endedAt: null, + exitCode: null, + lastExitCode: null, + lastEndedAt: null, + uptimeMs: 0, + ports: [], + logPath: null, + updatedAt: "2026-04-30T12:00:00.000Z", + }; + const ade = (window as unknown as { ade: { + projectConfig: { get: ReturnType }; + processes: { listDefinitions: ReturnType; start: ReturnType }; + } }).ade; + ade.projectConfig.get.mockResolvedValue({ + effective: { processGroups: [] }, + shared: { processGroups: [], processes: [definition] }, + local: { processGroups: [], processes: [] }, + }); + ade.processes.listDefinitions.mockResolvedValue([definition]); + ade.processes.start.mockResolvedValue(runtime); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /^Run$/i })); + + await waitFor(() => { + expect(ade.processes.start).toHaveBeenCalledWith({ laneId: "lane-a", processId: "proc-1" }); + }); + expect((await screen.findByTestId("terminal-view")).textContent).toBe("terminal-run:pty-run"); + }); }); diff --git a/apps/desktop/src/renderer/components/run/RunPage.tsx b/apps/desktop/src/renderer/components/run/RunPage.tsx index 83b6c959a..5f79478fb 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { CaretDown, CaretUp, Folder, FolderOpen, Play, Plus, Stop, Terminal, X } from "@phosphor-icons/react"; +import { CaretDown, CaretUp, Folder, FolderOpen, Play, Plus, Stop, Terminal } from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; import { CommandCard } from "./CommandCard"; @@ -10,6 +10,8 @@ import { RunNetworkPanel } from "./RunNetworkPanel"; import { commandArrayToLine, parseCommandLine } from "../../lib/shell"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { toRelativeTime } from "../graph/graphHelpers"; +import { isActiveProcessStatus } from "./processUtils"; +import { ChatTerminalDrawer, ChatTerminalToggle } from "../chat/ChatTerminalDrawer"; import type { ConfigProcessDefinition, ProcessDefinition, @@ -25,13 +27,6 @@ function generateId(): string { return Math.random().toString(36).slice(2, 10); } -type RunShellSession = { - sessionId: string; - ptyId: string; - title: string; - laneId: string; -}; - function parseEnvText(text: string): Record | undefined { const trimmed = text.trim(); if (!trimmed) return undefined; @@ -407,12 +402,19 @@ export function RunPage() { const [loading, setLoading] = useState(false); const [editingProcess, setEditingProcess] = useState<{ id: string; values: AddCommandInitialValues } | null>(null); const [actionError, setActionError] = useState(null); - const [runShellSessions, setRunShellSessions] = useState([]); - const [shellBusy, setShellBusy] = useState(false); const [networkDrawerOpen, setNetworkDrawerOpen] = useState(false); const [laneRuntimeBarOpen, setLaneRuntimeBarOpen] = useState(readLaneRuntimeBarOpenFromStorage); + const [terminalDrawerOpen, setTerminalDrawerOpen] = useState(false); + const [terminalCreateRequestNonce, setTerminalCreateRequestNonce] = useState(0); + const [terminalRevealRequest, setTerminalRevealRequest] = useState<{ + terminalId: string; + ptyId: string; + label: string; + nonce: number; + } | null>(null); const runtimeRefreshTimerRef = useRef(null); - const runShellSessionsRef = useRef([]); + const pendingRunLaunchRef = useRef<{ laneId: string; processId: string } | null>(null); + const terminalRevealNonceRef = useRef(0); const fallbackRunLaneId = useMemo( () => lanes.find((lane) => lane.laneType === "primary")?.id ?? lanes[0]?.id ?? null, @@ -469,15 +471,6 @@ export function RunPage() { }); }, [definitions, lanes, projectRoot, refreshLanePersistence]); - runShellSessionsRef.current = runShellSessions; - - const disposeRunShellSessions = useCallback(async (sessions: RunShellSession[]) => { - if (sessions.length === 0) return; - await Promise.allSettled( - sessions.map((session) => window.ade.pty.dispose({ ptyId: session.ptyId, sessionId: session.sessionId })), - ); - }, []); - useEffect(() => { logRendererDebugEvent("renderer.run.page_mount"); return () => { @@ -485,12 +478,6 @@ export function RunPage() { }; }, []); - useEffect(() => { - return () => { - void disposeRunShellSessions(runShellSessionsRef.current); - }; - }, [disposeRunShellSessions]); - const refreshDefinitions = useCallback(async () => { if (showWelcome) { setConfig(null); @@ -521,7 +508,6 @@ export function RunPage() { const laneIds = Array.from( new Set([ ...Object.values(commandLaneMap), - ...runShellSessions.map((session) => session.laneId), ].filter((value): value is string => Boolean(value))), ); if (laneIds.length === 0) { @@ -537,7 +523,7 @@ export function RunPage() { } catch (error) { console.error("RunPage.refreshRuntime", error); } - }, [commandLaneMap, runShellSessions, showWelcome]); + }, [commandLaneMap, showWelcome]); useEffect(() => { if (showWelcome) return; @@ -575,22 +561,47 @@ export function RunPage() { }; }, [refreshRuntime]); + const upsertRuntime = useCallback((nextRuntime: ProcessRuntime) => { + setRuntime((current) => { + const next = [...current]; + const index = next.findIndex((runtimeItem) => runtimeItem.runId === nextRuntime.runId); + if (index >= 0) { + next[index] = nextRuntime; + } else { + next.unshift(nextRuntime); + } + return next; + }); + }, []); + + const revealRuntimeTerminal = useCallback((runtimeItem: ProcessRuntime): boolean => { + if (!runtimeItem.sessionId || !runtimeItem.ptyId) return false; + const definition = definitions.find((item) => item.id === runtimeItem.processId); + const lane = lanes.find((item) => item.id === runtimeItem.laneId); + terminalRevealNonceRef.current += 1; + setTerminalDrawerOpen(true); + setTerminalRevealRequest({ + terminalId: runtimeItem.sessionId, + ptyId: runtimeItem.ptyId, + label: definition?.name ?? lane?.name ?? "Run command", + nonce: terminalRevealNonceRef.current, + }); + return true; + }, [definitions, lanes]); + useEffect(() => { const unsubscribe = window.ade.processes.onEvent((event: ProcessEvent) => { if (event.type !== "runtime") return; - setRuntime((current) => { - const next = [...current]; - const index = next.findIndex((runtimeItem) => runtimeItem.runId === event.runtime.runId); - if (index >= 0) { - next[index] = event.runtime; - } else { - next.unshift(event.runtime); + upsertRuntime(event.runtime); + const pending = pendingRunLaunchRef.current; + if (pending?.laneId === event.runtime.laneId && pending.processId === event.runtime.processId) { + if (revealRuntimeTerminal(event.runtime) || !isActiveProcessStatus(event.runtime.status)) { + pendingRunLaunchRef.current = null; } - return next; - }); + } }); return unsubscribe; - }, []); + }, [revealRuntimeTerminal, upsertRuntime]); const resolveProcessLaneId = useCallback((processId: string): string | null => { return commandLaneMap[processId] ?? fallbackRunLaneId ?? null; @@ -624,14 +635,23 @@ export function RunPage() { const handleRun = useCallback(async (processId: string) => { const laneId = resolveProcessLaneId(processId); if (!laneId) return; + pendingRunLaunchRef.current = { laneId, processId }; try { setActionError(null); - await startProcess(processId, laneId); + const started = await startProcess(processId, laneId); + upsertRuntime(started); + if (revealRuntimeTerminal(started)) { + pendingRunLaunchRef.current = null; + } } catch (error) { + const pending = pendingRunLaunchRef.current; + if (pending?.laneId === laneId && pending.processId === processId) { + pendingRunLaunchRef.current = null; + } setActionError(error instanceof Error ? error.message : String(error)); console.error("[RunPage] handleRun failed:", error); } - }, [resolveProcessLaneId, startProcess]); + }, [resolveProcessLaneId, revealRuntimeTerminal, startProcess, upsertRuntime]); const handleKillRuntime = useCallback(async (runtimeItem: ProcessRuntime) => { try { @@ -647,6 +667,12 @@ export function RunPage() { } }, []); + const handleOpenRuntimeTerminal = useCallback((runtimeItem: ProcessRuntime) => { + setActionError(null); + if (revealRuntimeTerminal(runtimeItem)) return; + setActionError("This run no longer has a live terminal attached."); + }, [revealRuntimeTerminal]); + const buildLaneMapForSelectedGroup = useCallback((): Record | null => { if (!selectedGroupId) return null; const laneByProcessId: Record = {}; @@ -715,41 +741,12 @@ export function RunPage() { } }, [config, newGroupName, refreshDefinitions]); - const handleLaunchShell = useCallback(async () => { - const laneId = fallbackRunLaneId; - if (!laneId || shellBusy) return; - setShellBusy(true); + const handleLaunchShell = useCallback(() => { + if (!fallbackRunLaneId) return; setActionError(null); - try { - const existingCount = runShellSessionsRef.current.length; - const title = existingCount > 0 ? `Shell ${existingCount + 1}` : "Shell"; - const result = await window.ade.pty.create({ - laneId, - cols: 100, - rows: 30, - title, - tracked: false, - toolType: "shell", - }); - const session: RunShellSession = { sessionId: result.sessionId, ptyId: result.ptyId, title, laneId }; - setRunShellSessions((current) => [...current, session]); - } catch (error) { - setActionError(error instanceof Error ? error.message : String(error)); - } finally { - setShellBusy(false); - } - }, [fallbackRunLaneId, shellBusy]); - - const handleCloseRunShell = useCallback(async (sessionId: string) => { - const target = runShellSessionsRef.current.find((session) => session.sessionId === sessionId); - setRunShellSessions((current) => current.filter((session) => session.sessionId !== sessionId)); - if (!target) return; - try { - await window.ade.pty.dispose({ ptyId: target.ptyId, sessionId: target.sessionId }); - } catch { - // ignore shell disposal failures in the Run tab - } - }, []); + setTerminalCreateRequestNonce((nonce) => nonce + 1); + setTerminalDrawerOpen(true); + }, [fallbackRunLaneId]); const saveProcessToConfig = useCallback(async (cmd: AddCommandSubmitPayload) => { if (!config) { @@ -937,19 +934,23 @@ export function RunPage() { Advanced + {fallbackRunLaneId ? ( + setTerminalDrawerOpen((open) => !open)} /> + ) : null} + {selectedGroupId ? ( @@ -1195,6 +1196,7 @@ export function RunPage() { onDelete={handleDeleteProcess} onAddToGroup={handleAddProcessToGroup} onKillRuntime={handleKillRuntime} + onOpenRuntime={handleOpenRuntimeTerminal} /> ); })} @@ -1220,73 +1222,18 @@ export function RunPage() { ) : null} - {runShellSessions.length > 0 ? ( -
- - Shells - - {runShellSessions.map((session) => { - const shellLaneName = lanes.find((item) => item.id === session.laneId)?.name ?? session.laneId; - return ( -
- - {session.title} - {shellLaneName} - -
- ); - })} -
+ {fallbackRunLaneId ? ( + setTerminalDrawerOpen((open) => !open)} + laneId={fallbackRunLaneId} + autoCreateOnOpen={false} + revealRequest={terminalRevealRequest} + createRequestNonce={terminalCreateRequestNonce} + disposeTabsOnUnmount + emptyMessage="Open a shell or run a command to attach a terminal." + onCreateError={setActionError} + /> ) : null}