diff --git a/CHANGELOG.md b/CHANGELOG.md index 879ca30a3..394b7d751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,12 @@ All notable changes to Pane will be documented in this file. ### Changed - Custom-command keyboard shortcuts moved from `mod+alt+5..9` to `mod+alt+6..9` to make room for the Cursor slot. - Cursor Agent is now available inside WSL repositories. +- Repository main panes now show the default shell in the bottom dock. The dock defaults to expanded in both main and worktree panes, while preserving saved collapse preferences. - `runpane watch` defaults are unchanged and stay responsive: no settle, no batching, all event kinds, IDLE every 10 minutes, HEARTBEAT every 60 seconds under `--follow`. - The Pane Chat orchestrator's Liveness Contract now arms the cadence flags above, filters HEARTBEAT out of its monitor, and judges a dead watch by a non-zero exit or a `WATCH ERROR` line rather than by silence. ### Fixed +- Agent terminals remain in working tabs after deleting the default shell and switching panes. Closing the dock also repairs tab selection when another shell takes its place. - Pane Chat has its skills again. Pane synced its agents' skills from the skills repository, which moved them on 2026-08-13; every sync since then failed, and new installs had none. Pane now ships 37 skills: Agent Farm's current raw-profile skills and their helpers, general-purpose primitives such as `verify-app`, `options`, `brief`, and `orchestrate-sessions`, and three Pane-specific ones (`pane-orchestrator`, `runpane`, `pane-work`). It also installs four helper subagents (explorer, cold-reader, qa-and-verify, reviewer) for Claude and Codex Sessions. The skill sync is removed, and Pane deletes its old sync folders. - `runpane panels submit` now submits prompts to Claude panes instead of leaving them in the composer. Pane waits for Claude's composer, types the prompt, and presses Enter separately once the text shows, so a pane created with `--wait-ready` and submitted to right away starts the turn. `verifiedSubmitted` is true only when Claude's composer is seen empty afterwards. - `runpane panels screen` reports `composer.hasUndeliveredText` for Claude panes. It used to read false for every Claude pane, even with a prompt sitting in the composer. Claude's dim placeholder suggestion does not count. diff --git a/docs/ADDING_NEW_CLI_TOOLS.md b/docs/ADDING_NEW_CLI_TOOLS.md index 2db4d25d5..76a21a908 100644 --- a/docs/ADDING_NEW_CLI_TOOLS.md +++ b/docs/ADDING_NEW_CLI_TOOLS.md @@ -62,7 +62,10 @@ add a ready detector like `createCursorReadyDetector` and gate `signalCliReady` fixtures and register it in `MANIFESTS_BY_AGENT`. Unknown agents fall back to `GENERIC_MANIFEST` (works, less precise). Keep blocker rules narrow and live-region gated so answered prompts in scrollback don't stick. Record the CLI version the -fixtures came from. +fixtures came from. Set `visibleIdle` only for reliable idle/completion evidence; +composers that remain visible mid-turn must not set it. The monitor prioritizes +blockers and working chrome, then reliable idle evidence, with byte activity as +a fallback. Unclassified boot output is ignored during the startup grace window. ## 5. Restart/auto-resume diff --git a/docs/remote-daemon-lifecycle.md b/docs/remote-daemon-lifecycle.md index 19c86c97f..8ff42a122 100644 --- a/docs/remote-daemon-lifecycle.md +++ b/docs/remote-daemon-lifecycle.md @@ -31,3 +31,9 @@ This is the implementation checklist for Remote Pane setup, teardown, and runtim - Connected remote clients are runtime state, not saved client records. - Current Pane Data hosting is live only while that Pane app is running. - Isolated daemon data can install a background service; Current Pane Data should not. + +## Terminal Input + +Desktop and browser clients send at most one terminal input request per panel at a time. Keys typed while a request is pending are combined into the next request, preserving their order without a separate round trip for every buffered key. A bare Escape always ends a combined request, because terminal apps read Escape followed by another key in the same write as an Alt shortcut. Different panels and other remote commands remain independent. + +Input requests are not retried. On failure, disconnect, or a ten-second input timeout, queued input is discarded and outstanding callers are rejected. An interrupted request may already have reached the host, so its input must not be replayed after reconnecting. This uses the existing HTTP API and requires no host protocol upgrade. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a85035fd..775b15d7e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,7 +28,7 @@ import { DISCORD_INVITE_URL } from './components/DiscordIcon'; import { ResumeSessionsDialog } from './components/ResumeSessionsDialog'; import { useErrorStore } from './stores/errorStore'; import { useSessionStore } from './stores/sessionStore'; -import { rollupSessionAgentState } from './utils/agentStatus'; +import { subscribePanelStatus } from './services/panelStatusSync'; import { useConfigStore } from './stores/configStore'; import { usePanelStore } from './stores/panelStore'; import { API } from './utils/api'; @@ -150,36 +150,8 @@ function App() { useIPCEvents(); const { showNotification } = useNotifications(); - // Global panel activity status listener - useEffect(() => { - const unsubscribe = window.electronAPI?.events?.onPanelActivityStatus?.((data) => { - usePanelStore.getState().setActivityStatus(data.panelId, data.status, data.lastActivityAt); - }); - return () => unsubscribe?.(); - }, []); - - // Global agent status listener (blocked / working / done) for terminal panels. - useEffect(() => { - const unsubscribe = window.electronAPI?.events?.onPanelAgentStatus?.((data) => { - const store = usePanelStore.getState(); - const prevState = store.agentStatus[data.panelId]; - store.setAgentStatus(data.panelId, data.sessionId, data.state); - - // A background agent finishing should read as done (blue) right away — - // mark unseen completion from the unified working -> idle transition - // instead of waiting for the legacy 30s activity flip. - if (prevState === 'working' && data.state === 'idle') { - const next = usePanelStore.getState(); - const activeSessionId = useSessionStore.getState().activeSessionId; - const sessionSettled = - rollupSessionAgentState(next.agentStatus, next.agentStatusSession, data.sessionId) === 'idle'; - if (sessionSettled && activeSessionId !== data.sessionId) { - next.markUnviewedCompletedActivity(data.sessionId); - } - } - }); - return () => unsubscribe?.(); - }, []); + // Subscribe to live statuses and reconcile the daemon baseline on attach/reconnect. + useEffect(() => subscribePanelStatus(), []); useEffect(() => { const clearViewedCompletedActivity = (event: Event) => { diff --git a/frontend/src/components/ProjectView.tsx b/frontend/src/components/ProjectView.tsx index 1b3e1355f..e233641c1 100644 --- a/frontend/src/components/ProjectView.tsx +++ b/frontend/src/components/ProjectView.tsx @@ -4,6 +4,9 @@ import { useSessionStore } from '../stores/sessionStore'; import type { Session } from '../types/session'; import { PanelTabBar } from './panels/PanelTabBar'; import { PanelContainer } from './panels/PanelContainer'; +import { TerminalDock } from './panels/TerminalDock'; +import { EmptyPanelStage } from './panels/EmptyPanelStage'; +import { getDockTerminalPanel } from '../utils/terminalDock'; import { usePanelStore } from '../stores/panelStore'; import { panelApi } from '../services/panelApi'; import type { ToolPanel, ToolPanelType } from '../../../shared/types/panels'; @@ -28,6 +31,8 @@ interface ProjectViewProps { projectEnvironment: ProjectEnvironment | undefined; configuredIDECommand?: string | null; onConfigureIDE: () => void; + isTerminalCollapsed: boolean; + onToggleTerminal: () => void; } export const ProjectView: React.FC = ({ @@ -36,9 +41,12 @@ export const ProjectView: React.FC = ({ projectEnvironment, configuredIDECommand, onConfigureIDE, + isTerminalCollapsed, + onToggleTerminal, }) => { const [mainRepoSessionId, setMainRepoSessionId] = useState(null); const [mainRepoSession, setMainRepoSession] = useState(null); + const [restoredPanelSessionId, setRestoredPanelSessionId] = useState(null); const [branchState, setBranchState] = useState<{ projectId: number; worktreePath: string | null; @@ -89,6 +97,7 @@ export const ProjectView: React.FC = ({ const immersiveMode = useNavigationStore(s => s.immersiveMode); const projectContentBox = useObservedContentBox(); + const centerColumnBox = useObservedContentBox(); const detailResize = useOuterPanelResize({ config: OUTER_PANEL_CONFIGS.projectInspector, containerPx: projectContentBox.width, @@ -97,23 +106,30 @@ export const ProjectView: React.FC = ({ // Load panels when main repo session changes (no auto-creation, matches worktree session behavior) useEffect(() => { + setRestoredPanelSessionId(null); + let cancelled = false; if (mainRepoSessionId) { panelApi.loadPanelsForSession(mainRepoSessionId).then(async (loadedPanels) => { + if (cancelled) return; setPanels(mainRepoSessionId, loadedPanels); // Pick default active: the first working panel (Explorer and Review // live in the inspector, not the stage). - const fallback = loadedPanels.find(p => p.type !== 'diff' && p.type !== 'explorer'); + const dock = getDockTerminalPanel(loadedPanels); + const fallback = loadedPanels.find(p => p.type !== 'diff' && p.type !== 'explorer' && p.id !== dock?.id); const activePanel = await panelApi.getActivePanel(mainRepoSessionId); + if (cancelled) return; if (activePanel) { setActivePanelInStore(mainRepoSessionId, activePanel.id); } else if (fallback) { setActivePanelInStore(mainRepoSessionId, fallback.id); await panelApi.setActivePanel(mainRepoSessionId, fallback.id); } + if (!cancelled) setRestoredPanelSessionId(mainRepoSessionId); }); } + return () => { cancelled = true; }; }, [mainRepoSessionId, setPanels, setActivePanelInStore]); // Get panels for current main repo session @@ -124,9 +140,10 @@ export const ProjectView: React.FC = ({ const filesPanel = useMemo(() => sessionPanels.find(p => p.type === 'explorer'), [sessionPanels]); const changesPanel = useMemo(() => sessionPanels.find(p => p.type === 'diff'), [sessionPanels]); + const defaultTerminalPanel = useMemo(() => getDockTerminalPanel(sessionPanels), [sessionPanels]); const workingPanels = useMemo( - () => sessionPanels.filter(p => p.type !== 'explorer' && p.type !== 'diff'), - [sessionPanels] + () => sessionPanels.filter(p => p.type !== 'explorer' && p.type !== 'diff' && p.id !== defaultTerminalPanel?.id), + [sessionPanels, defaultTerminalPanel] ); const currentActivePanel = useMemo( @@ -134,24 +151,28 @@ export const ProjectView: React.FC = ({ [workingPanels, activePanels, mainRepoSessionId] ); - // A persisted active panel that now lives in the inspector opens that tab - // and hands the stage to the first working panel. + // Keep the stage selection and persisted active panel in agreement when + // the active panel moves into the inspector or dock, or is deleted. const staleActiveHandledRef = useRef(null); useEffect(() => { - if (!mainRepoSessionId) return; + // A missing selection during loading must not overwrite the saved tab. + if (!mainRepoSessionId || restoredPanelSessionId !== mainRepoSessionId) return; const activeId = activePanels[mainRepoSessionId]; const stale = activeId ? sessionPanels.find(p => p.id === activeId && (p.type === 'explorer' || p.type === 'diff')) : undefined; - if (!stale) return; - const key = `${mainRepoSessionId}:${stale.id}`; - if (staleActiveHandledRef.current === key) return; - staleActiveHandledRef.current = key; - setInspectorTab(stale.type === 'diff' ? 'changes' : 'files'); + if (stale) { + const key = `${mainRepoSessionId}:${stale.id}`; + if (staleActiveHandledRef.current !== key) { + staleActiveHandledRef.current = key; + setInspectorTab(stale.type === 'diff' ? 'changes' : 'files'); + } + } + if (workingPanels.some(p => p.id === activeId)) return; const next = workingPanels[0]; if (next) { setActivePanelInStore(mainRepoSessionId, next.id); void panelApi.setActivePanel(mainRepoSessionId, next.id); } - }, [mainRepoSessionId, activePanels, sessionPanels, workingPanels, setActivePanelInStore]); + }, [mainRepoSessionId, restoredPanelSessionId, activePanels, sessionPanels, workingPanels, setActivePanelInStore]); const detailSession = useMemo(() => { if (!activeMainRepoSession || !displayBranch) return activeMainRepoSession; @@ -199,15 +220,21 @@ export const ProjectView: React.FC = ({ async (panel: ToolPanel) => { if (!mainRepoSessionId) return; - // Activate the neighbouring working tab (never an inspector panel). - const panelIndex = workingPanels.findIndex(p => p.id === panel.id); - const nextPanel = workingPanels[panelIndex + 1] || workingPanels[panelIndex - 1]; - // Remove from store first for immediate UI update removePanel(mainRepoSessionId, panel.id); + // The dock isn't in workingPanels, and closing it may promote another + // shell. Choose from the tabs that remain after that promotion. + const remaining = usePanelStore.getState().panels[mainRepoSessionId] || []; + const dock = getDockTerminalPanel(remaining); + const remainingTabs = remaining.filter(p => p.type !== 'explorer' && p.type !== 'diff' && p.id !== dock?.id); + const panelIndex = workingPanels.findIndex(p => p.id === panel.id); + const nextPanel = panelIndex === -1 + ? remainingTabs[0] + : remainingTabs[Math.min(panelIndex, remainingTabs.length - 1)]; + // Set next active panel if available - if (nextPanel) { + if (nextPanel && activePanels[mainRepoSessionId] === panel.id) { setActivePanelInStore(mainRepoSessionId, nextPanel.id); await panelApi.setActivePanel(mainRepoSessionId, nextPanel.id); } @@ -215,7 +242,7 @@ export const ProjectView: React.FC = ({ // Delete on backend await panelApi.deletePanel(panel.id); }, - [mainRepoSessionId, workingPanels, removePanel, setActivePanelInStore] + [mainRepoSessionId, workingPanels, activePanels, removePanel, setActivePanelInStore] ); const handlePanelCreate = useCallback( @@ -243,9 +270,12 @@ export const ProjectView: React.FC = ({ // The panel:created event will also fire, but addPanel checks for duplicates addPanel(newPanel); setActivePanelInStore(mainRepoSessionId, newPanel.id); + if (isTerminalCollapsed && getDockTerminalPanel(usePanelStore.getState().panels[mainRepoSessionId] || [])?.id === newPanel.id) { + onToggleTerminal(); + } return newPanel; }, - [mainRepoSessionId, addPanel, setActivePanelInStore] + [mainRepoSessionId, addPanel, setActivePanelInStore, isTerminalCollapsed, onToggleTerminal] ); const handleOpenUrlInBrowser = useCallback(async (url: string, title: string) => { @@ -423,59 +453,64 @@ export const ProjectView: React.FC = ({ {/* Content area: center panels + right detail */}
- {/* Center: panel content */} -
- {isLoadingSession ? ( -
-
-
-
-
-
+
+ {/* Center: panel content */} +
+ {isLoadingSession ? ( +
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- ) : workingPanels.length > 0 && currentActivePanel ? ( - workingPanels.map(panel => { - const isActive = panel.id === currentActivePanel.id; - return ( -
- +
+
+
+
+
+
- ); - }) - ) : ( -
- -
+
+ ) : workingPanels.length > 0 && currentActivePanel ? ( + workingPanels.map(panel => { + const isActive = panel.id === currentActivePanel.id; + return ( +
+ +
+ ); + }) + ) : ( + + )} +
+ {defaultTerminalPanel && ( +
diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index 310bc7a78..ab9470994 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -29,6 +29,9 @@ import { panelApi } from '../services/panelApi'; import { setPendingViewCommit } from './panels/diff/pendingViewCommit'; import { PanelTabBar } from './panels/PanelTabBar'; import { PanelContainer } from './panels/PanelContainer'; +import { TerminalDock } from './panels/TerminalDock'; +import { EmptyPanelStage } from './panels/EmptyPanelStage'; +import { getDockTerminalPanel } from '../utils/terminalDock'; import { SplitLayout } from './panels/SplitLayout'; import { SessionProvider } from '../contexts/SessionContext'; import { ToolPanel, ToolPanelType, PANEL_CAPABILITIES, SessionPanelLayout, PanelGroupNode } from '../../../shared/types/panels'; @@ -52,15 +55,12 @@ import { mergeAllGroups, type DropZone, } from '../utils/panelLayout'; -import { Download, Upload, GitMerge, GitPullRequestArrow, Terminal, ChevronDown, ChevronUp, RefreshCw, Archive, ArchiveRestore, GitCommitHorizontal, TerminalSquare, Undo2 } from 'lucide-react'; -import { getCliBrandIcon } from './ui/brandIconRegistry'; +import { Download, Upload, GitMerge, GitPullRequestArrow, Terminal, RefreshCw, Archive, ArchiveRestore, GitCommitHorizontal, Undo2 } from 'lucide-react'; import { visibleAgentPresets } from '../utils/agentPresets'; import type { Project } from '../types/project'; import { devLog, renderLog } from '../utils/console'; import { useConfigStore } from '../stores/configStore'; import { cycleIndex } from '../utils/arrayUtils'; -import { formatKeyDisplay } from '../utils/hotkeyUtils'; -import { Kbd } from './ui/Kbd'; import type { InspectorTab } from './InspectorTabs'; import { useErrorStore } from '../stores/errorStore'; import ProjectSettings from './ProjectSettings'; @@ -178,6 +178,13 @@ export const SessionView = memo(() => { // repaired centrally instead of in each caller. A collapse that removed the // focused group falls back to the primary group; a dead zoom target clears. const applyLayout = useCallback((sessionId: string, next: SessionPanelLayout) => { + // Closing the dock can promote a working shell. Remove its old tab before + // repairing focus/zoom and persisting, for both local and backend deletes. + const dock = getDockTerminalPanel(usePanelStore.getState().panels[sessionId] || []); + if (dock && findGroupContainingPanel(next.root, dock.id)) { + const root = removePanelFromLayout(next.root, dock.id); + next = root ? { ...next, root } : createSingleGroupLayout([], null); + } let focusedGid = next.focusedGroupId; if (!focusedGid || !findGroup(next.root, focusedGid)) { focusedGid = primaryGroup(next.root).id; @@ -262,10 +269,10 @@ export const SessionView = memo(() => { } // --- Layout load + reconcile --- - // The pinned terminal (first terminal) is excluded from the layout tree + // The dock shell is excluded from the layout tree // and so are the inspector panels (Explorer / Review), which never // sit on the stage — otherwise a close could hand the group to one. - const pinned = loadedPanels.find(p => p.type === 'terminal'); + const pinned = getDockTerminalPanel(loadedPanels); const livePanels = loadedPanels.filter(p => p.id !== pinned?.id && !isInspectorPanelType(p.type)); // Sort for initial layout creation (explorer first, diff second, then position) @@ -287,7 +294,7 @@ export const SessionView = memo(() => { // but not in the loadedPanels snapshot. Reconciling against the // current store adopts them as orphans instead of dropping them. const nowPanels = usePanelStore.getState().panels[sid] || []; - const pinnedNow = nowPanels.find(p => p.type === 'terminal'); + const pinnedNow = getDockTerminalPanel(nowPanels); const liveIdsNow: string[] = []; for (const p of nowPanels) { if (p.id !== pinnedNow?.id && !isInspectorPanelType(p.type)) liveIdsNow.push(p.id); @@ -338,10 +345,10 @@ export const SessionView = memo(() => { if (!panelExists) { addPanel(panel); - // The pinned terminal (first terminal in the session) never enters + // The dock shell never enters // the layout tree const sessionPanelsList = usePanelStore.getState().panels[sid] || []; - const pinnedTerminal = sessionPanelsList.find(p => p.type === 'terminal'); + const pinnedTerminal = getDockTerminalPanel(sessionPanelsList); if (pinnedTerminal && panel.id === pinnedTerminal.id) { return; } @@ -412,9 +419,9 @@ export const SessionView = memo(() => { if (activeSession?.id && activeSessionPanelsLoaded) markPaneViewShown(activeSession.id); }, [activeSession?.id, activeSessionPanelsLoaded]); - // Bottom terminal panel (first terminal panel in session) + // The bottom dock owns a plain shell, never an agent or command panel. const defaultTerminalPanel = useMemo( - () => sessionPanels.find(p => p.type === 'terminal'), + () => getDockTerminalPanel(sessionPanels), [sessionPanels] ); @@ -974,12 +981,6 @@ export const SessionView = memo(() => { }; } - // Captured BEFORE the create: if the session has no terminal yet, the - // panel we are about to create becomes the pinned dock terminal and - // must never enter the layout tree. - const hadTerminalBefore = (usePanelStore.getState().panels[sid] || []) - .some(p => p.type === 'terminal'); - const newPanel = await panelApi.createPanel({ sessionId: sid, type, @@ -991,8 +992,11 @@ export const SessionView = memo(() => { addPanel(newPanel); setActivePanelInStore(sid, newPanel.id); - const becomesPinnedTerminal = type === 'terminal' && !hadTerminalBefore; - if (becomesPinnedTerminal) return newPanel; + const dock = getDockTerminalPanel(usePanelStore.getState().panels[sid] || []); + if (dock?.id === newPanel.id) { + setIsTerminalCollapsed(false); + return newPanel; + } // Add to layout (into the focused group, falling back to the primary // group if focus is stale). addPanelToGroup is idempotent, so racing @@ -1178,49 +1182,9 @@ export const SessionView = memo(() => { setDropZones(new Map()); }, [primaryGroupId, primaryGroupNode, activeSession, isSplitLayout, topBarPanels, applyLayout]); - const hotkeys = useHotkeyStore((s) => s.hotkeys); - const hotkeyDisplay = useCallback((id: string) => { - const keys = hotkeys.get(id)?.keys; - return keys ? formatKeyDisplay(keys) : null; - }, [hotkeys]); - - // The empty stage is the "+" menu laid out inline: one click (or the - // shortcut beside it) from a running tool, instead of a placeholder. const emptyStage = useMemo(() => ( -
-
-
Open
- {[ - { key: 'terminal', label: 'Terminal', icon: , hotkeyId: 'add-tool-terminal', onClick: () => handlePanelCreate('terminal') }, - ...agentPresets.map(preset => ({ - key: preset.id, - label: preset.title, - icon: getCliBrandIcon(preset.iconKey, 'h-3.5 w-3.5'), - hotkeyId: preset.hotkeyId, - onClick: () => handlePanelCreate('terminal', { initialCommand: preset.command, title: preset.title }), - })), - ...customCommands.map((cmd, index) => ({ - key: `custom-${index}`, - label: cmd.name, - icon: getCliBrandIcon(cmd.command, 'h-3.5 w-3.5') || , - hotkeyId: `add-tool-custom-${index}`, - onClick: () => handlePanelCreate('terminal', { initialCommand: cmd.command, title: cmd.name }), - })), - ].map(item => ( - - ))} -
-
- ), [agentPresets, customCommands, handlePanelCreate, hotkeyDisplay]); + + ), [activeProjectEnvironment, handlePanelCreate]); // --- Editor stage element (shared by both layouts) --- const editorStageElement = useMemo(() => { @@ -1493,7 +1457,7 @@ export const SessionView = memo(() => { // Unless the user has explicitly closed it previously const hasTriedCreatingTerminal = useRef(false); useEffect(() => { - if (!activeSession?.id || defaultTerminalPanel || hasTriedCreatingTerminal.current) return; + if (!activeSession?.id || sessionPanels.some(p => p.type === 'terminal') || hasTriedCreatingTerminal.current) return; // Only attempt once per session to avoid loops hasTriedCreatingTerminal.current = true; @@ -1512,7 +1476,7 @@ export const SessionView = memo(() => { console.error('[SessionView] Failed to auto-create terminal panel:', err); }); }); - }, [activeSession?.id, defaultTerminalPanel, addPanel]); + }, [activeSession?.id, sessionPanels, addPanel]); // Reset the flag when session changes useEffect(() => { @@ -1535,10 +1499,10 @@ export const SessionView = memo(() => { } }, [immersiveMode, swappedLayoutRendered, toggleDetailCollapse]); - // Terminal collapse state with localStorage persistence (collapsed by default) + // Share the dock preference across project and session views; expand on first use. const [isTerminalCollapsed, setIsTerminalCollapsed] = useState(() => { const stored = localStorage.getItem('pane-terminal-collapsed'); - return stored === null ? true : stored === 'true'; + return stored === 'true'; }); useEffect(() => { @@ -1556,18 +1520,6 @@ export const SessionView = memo(() => { containerPx: sessionContentBox.width, enabled: !swappedLayoutRendered && detailVisible && !immersiveMode, }); - const terminalResize = useOuterPanelResize({ - config: OUTER_PANEL_CONFIGS.bottomTerminal, - containerPx: centerColumnBox.height, - enabled: Boolean(defaultTerminalPanel) && !swappedLayoutRendered && !isTerminalCollapsed && !immersiveMode, - }); - const terminalDockHeight = immersiveMode - ? terminalResize.renderedPx - : isTerminalCollapsed - ? Math.min(32, centerColumnBox.height) - : terminalResize.renderedPx; - // A zero-height dock is invisible: its chrome must not stay reachable. - const terminalDockContentActive = terminalDockHeight > 0; const rightTerminalResize = useOuterPanelResize({ config: OUTER_PANEL_CONFIGS.rightTerminal, containerPx: sessionContentBox.width, @@ -1777,6 +1729,8 @@ export const SessionView = memo(() => { projectEnvironment={projectData.environment} configuredIDECommand={projectData.open_ide_command} onConfigureIDE={() => setShowProjectSettings(true)} + isTerminalCollapsed={isTerminalCollapsed} + onToggleTerminal={toggleTerminalCollapse} /> { {editorStageElement || emptyStage}
- {/* Bottom: persistent terminal (collapsible) */} {defaultTerminalPanel && ( -
0) - ? 'border-t border-border-primary' - : '' - }`} - style={{ height: `${terminalDockHeight}px` }} - > - {terminalResize.separatorVisible && ( - - )} -
- {/* Terminal tab header with collapse toggle and pill shortcuts */} -
- {/* Left: chevron + icon + label */} - - - Terminal - -
-
- {/* Terminal content (hidden when collapsed) */} - {!isTerminalCollapsed && ( -
- -
- )} -
-
+
diff --git a/frontend/src/components/panels/EmptyPanelStage.tsx b/frontend/src/components/panels/EmptyPanelStage.tsx new file mode 100644 index 000000000..67c337c0c --- /dev/null +++ b/frontend/src/components/panels/EmptyPanelStage.tsx @@ -0,0 +1,58 @@ +import { Terminal, TerminalSquare } from 'lucide-react'; +import type { PanelTabBarProps } from '../../types/panelComponents'; +import { useConfigStore } from '../../stores/configStore'; +import { useHotkeyStore } from '../../stores/hotkeyStore'; +import { visibleAgentPresets } from '../../utils/agentPresets'; +import { formatKeyDisplay } from '../../utils/hotkeyUtils'; +import { getCliBrandIcon } from '../ui/brandIconRegistry'; +import { Kbd } from '../ui/Kbd'; + +type EmptyPanelStageProps = Pick; + +// The empty stage is the "+" menu laid out inline, shared by repo and worktree views. +export function EmptyPanelStage({ projectEnvironment, onPanelCreate }: EmptyPanelStageProps) { + const config = useConfigStore((s) => s.config); + const customCommands = (config?.customCommands ?? []).filter(cmd => cmd?.name && cmd?.command); + const agentPresets = visibleAgentPresets(projectEnvironment); + const hotkeys = useHotkeyStore((s) => s.hotkeys); + const hotkeyDisplay = (id: string) => { + const keys = hotkeys.get(id)?.keys; + return keys ? formatKeyDisplay(keys) : null; + }; + + return ( +
+
+
Open
+ {[ + { key: 'terminal', label: 'Terminal', icon: , hotkeyId: 'add-tool-terminal', onClick: () => onPanelCreate('terminal') }, + ...agentPresets.map(preset => ({ + key: preset.id, + label: preset.title, + icon: getCliBrandIcon(preset.iconKey, 'h-3.5 w-3.5'), + hotkeyId: preset.hotkeyId, + onClick: () => onPanelCreate('terminal', { initialCommand: preset.command, title: preset.title }), + })), + ...customCommands.map((cmd, index) => ({ + key: `custom-${index}`, + label: cmd.name, + icon: getCliBrandIcon(cmd.command, 'h-3.5 w-3.5') || , + hotkeyId: `add-tool-custom-${index}`, + onClick: () => onPanelCreate('terminal', { initialCommand: cmd.command, title: cmd.name }), + })), + ].map(item => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/panels/TerminalDock.tsx b/frontend/src/components/panels/TerminalDock.tsx new file mode 100644 index 000000000..decf75a82 --- /dev/null +++ b/frontend/src/components/panels/TerminalDock.tsx @@ -0,0 +1,87 @@ +import { ChevronDown, ChevronUp, Terminal, X } from 'lucide-react'; +import type { ToolPanel } from '../../../../shared/types/panels'; +import { useOuterPanelResize } from '../../hooks/useOuterPanelResize'; +import { OUTER_PANEL_CONFIGS } from '../../utils/outerPanelSizing'; +import { OuterResizeSeparator } from '../ui/OuterResizeSeparator'; +import { PanelContainer } from './PanelContainer'; + +interface TerminalDockProps { + panel: ToolPanel; + availableHeight: number; + collapsed: boolean; + hidden: boolean; + onToggle: () => void; + onClose: () => void; + isMainRepo: boolean; +} + +export function TerminalDock({ panel, availableHeight, collapsed, hidden, onToggle, onClose, isMainRepo }: TerminalDockProps) { + const resize = useOuterPanelResize({ + config: OUTER_PANEL_CONFIGS.bottomTerminal, + containerPx: availableHeight, + enabled: !collapsed && !hidden, + }); + const height = hidden ? 0 : collapsed ? Math.min(32, availableHeight) : resize.renderedPx; + const contentActive = height > 0; + + return ( +
0) ? 'border-t border-border-primary' : '' + }`} + style={{ height: `${height}px` }} + > + {resize.separatorVisible && ( + + )} +
+
+ + + Terminal +
+ {!panel.metadata.permanent && ( + + )} +
+ {!collapsed && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index cf618c959..677b3e449 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -21,6 +21,7 @@ import { } from '../../utils/terminalKeyHandling'; import { isMac } from '../../utils/platformUtils'; import { copyTerminalText, isTerminalCopyShortcut } from '../../utils/terminalClipboard'; +import { sendTerminalInput } from '../../utils/terminalInput'; import { FileEdit, FolderOpen } from 'lucide-react'; import { useTerminalLinks } from '../terminal/hooks/useTerminalLinks'; import { TerminalLinkTooltip } from '../terminal/TerminalLinkTooltip'; @@ -741,13 +742,17 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv // width — resizing after getState would replay a stale-width snapshot, // and the normal-buffer path has no forced app redraw left to repair it. await resizePtyToFit(); + // Switching sessions can dispose this xterm while a resize reply is pending. + if (xtermRef.current !== terminal) return; const state = await window.electronAPI.invoke('terminal:getState', panel.id); + if (xtermRef.current !== terminal) return; if (state?.isAlternateScreen) { // Renderer refresh alone cannot repair an application frame that was // restored before the visible grid settled. Ask main for a forced resize // (single PTY row nudge) so the foreground app receives a real resize // notification and repaints at the settled grid. await resizePtyToFit(true); + if (xtermRef.current !== terminal) return; if (terminal.rows > 0) { terminal.refresh(0, terminal.rows - 1); } @@ -807,6 +812,8 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv const wasNearBottom = isNearBottomRef.current || distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_ROWS; await resizePtyToFit(); + // A refocus refresh may finish after switching to another session's terminal. + if (xtermRef.current !== terminal) return; if (terminal.rows > 0) terminal.refresh(0, terminal.rows - 1); if (wasNearBottom) { @@ -1043,7 +1050,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv // passthrough, while ordinary TUIs still receive Shift+Enter directly. if (terminalKeyDecision.action === 'send-input') { if (e.type === 'keydown') { - window.electronAPI.invoke('terminal:input', panel.id, terminalKeyDecision.input); + sendTerminalInput(panel.id, terminalKeyDecision.input); } return false; } @@ -1355,11 +1362,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv if (!terminal) return; const shouldProtectMultilinePaste = isCliPanelRef.current && !tuiActiveRef.current && /[\r\n]/.test(text); if (shouldProtectMultilinePaste) { - window.electronAPI.invoke( - 'terminal:input', - panel.id, - text.replace(/\r\n|\r|\n/g, '\x1b\r'), - ); + sendTerminalInput(panel.id, text.replace(/\r\n|\r|\n/g, '\x1b\r')); return; } @@ -1647,7 +1650,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv // Create interceptor for @ mentions and future trigger handlers const interceptor = new TerminalInterceptor({ onStateChange: (state) => setInterceptorState(state.active ? state : null), - onFlush: (data) => window.electronAPI.invoke('terminal:input', panel.id, data), + onFlush: (data) => sendTerminalInput(panel.id, data), }); interceptorRef.current = interceptor; @@ -1738,12 +1741,12 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv // Skip interception for AltGr-produced @ (e.g. German keyboard) if (skipNextInterceptRef.current) { skipNextInterceptRef.current = false; - window.electronAPI.invoke('terminal:input', panel.id, data); + sendTerminalInput(panel.id, data); return; } const result = interceptor.handleInput(data); if (!result.consumed) { - window.electronAPI.invoke('terminal:input', panel.id, data); + sendTerminalInput(panel.id, data); } }); diff --git a/frontend/src/components/ui/AgentStatusDot.tsx b/frontend/src/components/ui/AgentStatusDot.tsx index 3d30ba82a..947c70d38 100644 --- a/frontend/src/components/ui/AgentStatusDot.tsx +++ b/frontend/src/components/ui/AgentStatusDot.tsx @@ -29,7 +29,7 @@ const containerSizeClasses = { /** * At-a-glance agent status indicator. Working renders as a blue spinner; blocked * (red), done (blue), and idle (green) render as a dot — the "dot + spinner" - * variation. Renders nothing for `unknown` so non-agent panels show no badge. + * variation. Renders nothing until a terminal has a known status. */ interface AgentActivityDotProps { active: boolean; diff --git a/frontend/src/components/ui/agentStatusVisual.ts b/frontend/src/components/ui/agentStatusVisual.ts index 63f09edc6..4b6e9e349 100644 --- a/frontend/src/components/ui/agentStatusVisual.ts +++ b/frontend/src/components/ui/agentStatusVisual.ts @@ -13,7 +13,7 @@ export interface AgentStatusVisual { * Single source of truth for how an {@link AgentDisplayStatus} looks: blocked is * red and pulses, working is the info blue (matching the label shimmer) and * pulses, a freshly finished agent is a blue - * "done" cue, a seen-idle agent is calm green. `unknown` (no agent / plain shell) + * "done" cue, a seen-idle agent is calm green. `unknown` (no detected terminal status) * returns null so callers render no badge. */ export function agentStatusVisual(status: AgentDisplayStatus): AgentStatusVisual | null { diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 5be900330..0dc9bd3d3 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -238,7 +238,11 @@ export function useNotifications() { // detected on their next transition — and an agent already sitting blocked // when the app opens doesn't re-ping. prevAgentStatusRef.current = { ...usePanelStore.getState().agentStatus }; - const unsubscribe = usePanelStore.subscribe((state) => { + const unsubscribe = usePanelStore.subscribe((state, previousState) => { + if (state.agentStatusSnapshotVersion !== previousState.agentStatusSnapshotVersion) { + prevAgentStatusRef.current = { ...state.agentStatus }; + return; + } const agentStatus = state.agentStatus; const prev = prevAgentStatusRef.current; for (const [panelId, status] of Object.entries(agentStatus)) { diff --git a/frontend/src/remote/runtime/remoteDaemonBrowserClient.ts b/frontend/src/remote/runtime/remoteDaemonBrowserClient.ts index ed0601194..741f9db56 100644 --- a/frontend/src/remote/runtime/remoteDaemonBrowserClient.ts +++ b/frontend/src/remote/runtime/remoteDaemonBrowserClient.ts @@ -7,6 +7,7 @@ import { type RemotePaneConnectionProfile, type RemotePaneConnectionStatus, } from '../../../../shared/types/remoteDaemon'; +import { RemoteInputQueue } from '../../../../shared/remoteInputQueue'; type RemoteBrowserEvent = | { type: 'ready'; timestamp: string } @@ -66,6 +67,8 @@ export class RemoteDaemonBrowserClient { private reconnectAttempt = 0; private eventListeners = new Set(); private statusListeners = new Set(); + private readonly inputQueue = new RemoteInputQueue((channel, args, signal) => + this.invokeRequest(channel, args, signal)); private state: RemoteBrowserConnectionState = { status: 'local', lastError: null, @@ -90,6 +93,7 @@ export class RemoteDaemonBrowserClient { } async connect(): Promise { + this.inputQueue.cancel(new Error('Remote Pane reconnecting; pending terminal input was discarded')); this.clearReconnectTimer(); this.abortController?.abort(); this.closeEventSource(); @@ -102,6 +106,7 @@ export class RemoteDaemonBrowserClient { } disconnect(): void { + this.inputQueue.cancel(new Error('Remote Pane disconnected; pending terminal input was discarded')); this.clearReconnectTimer(); this.abortController?.abort(); this.closeEventSource(); @@ -111,8 +116,13 @@ export class RemoteDaemonBrowserClient { } async invoke(channel: string, args: unknown[] = []): Promise { + // SAFETY: The named IPC/API channel contract establishes this response payload type. + return this.inputQueue.invoke(channel, args) as Promise; + } + + private async invokeRequest(channel: string, args: unknown[], inputSignal?: AbortSignal): Promise { let lastError: Error | null = null; - const signal = this.abortController?.signal; + const signal = inputSignal ?? this.abortController?.signal; const retryableRead = RETRYABLE_READ_CHANNELS.has(channel); const attempts = retryableRead ? INVOKE_ATTEMPTS : 1; for (let attempt = 1; attempt <= attempts; attempt += 1) { @@ -417,6 +427,7 @@ export class RemoteDaemonBrowserClient { } private scheduleReconnect(message: string): void { + this.inputQueue.cancel(new Error(message)); if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { this.setState({ status: 'error', lastError: message }); return; diff --git a/frontend/src/services/panelStatusSync.test.ts b/frontend/src/services/panelStatusSync.test.ts new file mode 100644 index 000000000..a5fd2f3fb --- /dev/null +++ b/frontend/src/services/panelStatusSync.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PanelAgentStatusEvent } from '../../../shared/types/agentStatus'; +import { usePanelStore } from '../stores/panelStore'; +import { subscribePanelStatus } from './panelStatusSync'; +import { useSessionStore } from '../stores/sessionStore'; +import type { JsonValue } from '../../../shared/validation/boundaryDecoder'; + +let status: (event: PanelAgentStatusEvent) => void; +let deleted: (event: { panelId: string; sessionId: string }) => void; +let resync: () => void; +let replies: Array<(value: JsonValue) => void>; +let cleanup: () => void; + +const snapshot = (state: string) => ({ success: true, data: [{ sessionId: 's', panelId: 'p', state }] }); +const event = (state: PanelAgentStatusEvent['state']) => ({ panelId: 'p', sessionId: 's', state, reason: null }); +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +beforeEach(() => { + usePanelStore.setState({ panels: {}, activityStatus: {}, agentStatus: {}, agentStatusSession: {}, unviewedCompletedActivity: {}, agentStatusSnapshotVersion: 0 }); + useSessionStore.setState({ activeSessionId: 'foreground' }); + replies = []; + vi.stubGlobal('window', { electronAPI: { + invoke: vi.fn(() => new Promise(resolve => replies.push(resolve))), + events: { + onPanelAgentStatus: (callback: typeof status) => { status = callback; return vi.fn(); }, + onPanelDeleted: (callback: typeof deleted) => { deleted = callback; return vi.fn(); }, + onRemoteDaemonResyncRequested: (callback: typeof resync) => { resync = callback; return vi.fn(); }, + }, + } }); + cleanup = subscribePanelStatus(); +}); + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +describe('panel status synchronization', () => { + it('hydrates an already-running background panel and repairs a missed transition on reconnect', async () => { + replies[0](snapshot('working')); + await flush(); + expect(usePanelStore.getState().getSessionAgentState('s')).toBe('working'); + expect(usePanelStore.getState().getPanelActivityStatus('p')).toBe('active'); + resync(); + replies[1](snapshot('idle')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('idle'); + expect(usePanelStore.getState().getSessionAgentState('s')).toBe('idle'); + expect(usePanelStore.getState().getPanelActivityStatus('p')).toBe('idle'); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(false); + expect(usePanelStore.getState().agentStatusSnapshotVersion).toBe(2); + }); + + it('keeps newer events over an older snapshot, including repeated same-state events', async () => { + status(event('working')); + replies[0](snapshot('idle')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('working'); + resync(); + status(event('working')); + replies[1](snapshot('blocked')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('working'); + }); + + it('removes a background panel and ignores its late exit and pending snapshot', async () => { + status(event('working')); + deleted({ panelId: 'p', sessionId: 's' }); + status({ ...event('idle'), reason: 'exit' }); + replies[0](snapshot('working')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBeUndefined(); + expect(usePanelStore.getState().getSessionAgentState('s')).toBe('unknown'); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(false); + }); + + it('prunes statuses missing from a successful snapshot', async () => { + replies[0](snapshot('working')); + await flush(); + resync(); + replies[1]({ success: true, data: [] }); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBeUndefined(); + }); + + it('ignores obsolete requests and replies after unsubscription', async () => { + resync(); + replies[1](snapshot('idle')); + await flush(); + replies[0](snapshot('working')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('idle'); + resync(); + cleanup(); + replies[2](snapshot('working')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('idle'); + }); + + it('allows a new snapshot to restore an ID recreated while disconnected', async () => { + deleted({ panelId: 'p', sessionId: 's' }); + replies[0](snapshot('working')); + await flush(); + resync(); + replies[1](snapshot('idle')); + await flush(); + status(event('working')); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('working'); + }); + + it('keeps known status if snapshot validation fails', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + status(event('working')); + replies[0]({ ok: false }); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('working'); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); + }); + + it.each(['exit', 'destroyed'])('silently clears working state on %s before panel deletion', async reason => { + replies[0](snapshot('working')); + await flush(); + const baseline = usePanelStore.getState().agentStatusSnapshotVersion; + status({ ...event('idle'), reason }); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('idle'); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(false); + expect(usePanelStore.getState().agentStatusSnapshotVersion).toBe(baseline + 1); + // The next lifetime can still produce a real completion. + status(event('working')); + status(event('idle')); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(true); + }); + + it('marks real live completion unseen after hydration', async () => { + replies[0](snapshot('idle')); + await flush(); + status(event('working')); + status(event('idle')); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(true); + }); +}); diff --git a/frontend/src/services/panelStatusSync.ts b/frontend/src/services/panelStatusSync.ts new file mode 100644 index 000000000..8439f1f51 --- /dev/null +++ b/frontend/src/services/panelStatusSync.ts @@ -0,0 +1,105 @@ +import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder'; +import { usePanelStore } from '../stores/panelStore'; +import { useSessionStore } from '../stores/sessionStore'; +import { rollupSessionAgentState } from '../utils/agentStatus'; + +const statusSnapshotSchema = boundary.object({ + success: boundary.literal(true), + data: boundary.array(boundary.object({ + sessionId: boundary.string, + panelId: boundary.string, + state: boundary.enumeration('blocked', 'working', 'idle', 'unknown'), + })), +}); + +/** Subscribe before requesting the baseline; events during a read always win. */ +export function subscribePanelStatus(): () => void { + const api = window.electronAPI; + let disposed = false; + let requestId = 0; + let changedDuringRead = new Set(); + const deleted = new Set(); + + const unsubscribeStatus = api.events.onPanelAgentStatus?.(data => { + changedDuringRead.add(data.panelId); + if (deleted.has(data.panelId)) return; + if (data.reason === 'exit' || data.reason === 'destroyed') { + // Rebaseline notification subscribers atomically, as with a snapshot. + // A stopped process must not become an unseen completed agent turn. + usePanelStore.setState(state => ({ + agentStatus: { ...state.agentStatus, [data.panelId]: data.state }, + agentStatusSession: { ...state.agentStatusSession, [data.panelId]: data.sessionId }, + agentStatusSnapshotVersion: state.agentStatusSnapshotVersion + 1, + })); + return; + } + const store = usePanelStore.getState(); + const prevState = store.agentStatus[data.panelId]; + store.setAgentStatus(data.panelId, data.sessionId, data.state); + if (prevState === 'working' && data.state === 'idle') { + const next = usePanelStore.getState(); + const activeSessionId = useSessionStore.getState().activeSessionId; + const sessionSettled = rollupSessionAgentState(next.agentStatus, next.agentStatusSession, data.sessionId) === 'idle'; + if (sessionSettled && activeSessionId !== data.sessionId) { + next.markUnviewedCompletedActivity(data.sessionId); + } + } + }); + const unsubscribeActivity = api.events.onPanelActivityStatus?.(data => { + if (!deleted.has(data.panelId)) { + usePanelStore.getState().setActivityStatus(data.panelId, data.status, data.lastActivityAt); + } + }); + const unsubscribeDeleted = api.events.onPanelDeleted?.(data => { + changedDuringRead.add(data.panelId); + deleted.add(data.panelId); + usePanelStore.getState().removePanel(data.sessionId, data.panelId); + }); + const unsubscribeCreated = api.events.onPanelCreated?.(panel => { + changedDuringRead.add(panel.id); + deleted.delete(panel.id); + }); + + const refresh = async () => { + const currentRequest = ++requestId; + changedDuringRead = new Set(); + try { + const response: unknown = await api.invoke('panels:agent-statuses'); + const snapshot = decodeBoundary(response, statusSnapshotSchema); + if (disposed || currentRequest !== requestId) return; + usePanelStore.setState(state => { + const agentStatus = { ...state.agentStatus }; + const agentStatusSession = { ...state.agentStatusSession }; + const activityStatus = { ...state.activityStatus }; + for (const panelId of Object.keys(agentStatus)) { + if (!changedDuringRead.has(panelId)) { + delete agentStatus[panelId]; + delete agentStatusSession[panelId]; + delete activityStatus[panelId]; + } + } + for (const panel of snapshot.data) { + if (changedDuringRead.has(panel.panelId)) continue; + deleted.delete(panel.panelId); + agentStatus[panel.panelId] = panel.state; + agentStatusSession[panel.panelId] = panel.sessionId; + activityStatus[panel.panelId] = panel.state === 'working' || panel.state === 'blocked' ? 'active' : 'idle'; + } + return { agentStatus, agentStatusSession, activityStatus, agentStatusSnapshotVersion: state.agentStatusSnapshotVersion + 1 }; + }); + } catch (error) { + if (!disposed && currentRequest === requestId) console.error('[panelStatusSync] Failed to refresh agent statuses:', error); + } + }; + const unsubscribeResync = api.events.onRemoteDaemonResyncRequested?.(() => { void refresh(); }); + void refresh(); + + return () => { + disposed = true; + unsubscribeStatus?.(); + unsubscribeActivity?.(); + unsubscribeDeleted?.(); + unsubscribeCreated?.(); + unsubscribeResync?.(); + }; +} diff --git a/frontend/src/stores/panelStore.test.ts b/frontend/src/stores/panelStore.test.ts index 591865b1d..2f0081158 100644 --- a/frontend/src/stores/panelStore.test.ts +++ b/frontend/src/stores/panelStore.test.ts @@ -45,6 +45,15 @@ describe('panelStore agent status', () => { expect(usePanelStore.getState().getSessionAgentState('s2')).toBe('unknown'); }); + it('prunes stale background status when panel lists are reloaded', () => { + const store = usePanelStore.getState(); + store.setAgentStatus('deleted', 'background', 'working'); + store.setAgentStatus('retained', 'background', 'idle'); + store.setPanels('background', [panel('retained', 'background')]); + expect(usePanelStore.getState().getPanelAgentState('deleted')).toBeUndefined(); + expect(usePanelStore.getState().getSessionAgentState('background')).toBe('idle'); + }); + it('clears agent status when a panel is removed', () => { const store = usePanelStore.getState(); store.setPanels('s1', [panel('a', 's1')]); diff --git a/frontend/src/stores/panelStore.ts b/frontend/src/stores/panelStore.ts index 2b6ab038f..ad1ec8ec8 100644 --- a/frontend/src/stores/panelStore.ts +++ b/frontend/src/stores/panelStore.ts @@ -12,6 +12,7 @@ export const usePanelStore = create()( activityStatus: {}, agentStatus: {}, agentStatusSession: {}, + agentStatusSnapshotVersion: 0, lastActivityAt: {}, unviewedCompletedActivity: {}, layouts: {}, @@ -20,6 +21,15 @@ export const usePanelStore = create()( // Pure synchronous state updates setPanels: (sessionId, panels) => { set((state) => { + const panelIds = new Set(panels.map(panel => panel.id)); + for (const [panelId, owner] of Object.entries(state.agentStatusSession)) { + if (owner === sessionId && !panelIds.has(panelId)) { + delete state.agentStatus[panelId]; + delete state.agentStatusSession[panelId]; + delete state.activityStatus[panelId]; + delete state.lastActivityAt[panelId]; + } + } // Replace panels array entirely to ensure React detects changes state.panels[sessionId] = panels; }); diff --git a/frontend/src/types/panelStore.ts b/frontend/src/types/panelStore.ts index 7fe16d56e..6a9a0bd49 100644 --- a/frontend/src/types/panelStore.ts +++ b/frontend/src/types/panelStore.ts @@ -8,6 +8,7 @@ export interface PanelStore { activityStatus: Record; // panelId -> status agentStatus: Record; // panelId -> detected agent state (blocked/working/idle) agentStatusSession: Record; // panelId -> sessionId (so status rolls up without panels loaded) + agentStatusSnapshotVersion: number; // Snapshots and terminal endings silently rebaseline notification subscribers lastActivityAt: Record; // panelId -> last PTY output timestamp unviewedCompletedActivity: Record; // sessionId -> completion timestamp diff --git a/frontend/src/utils/agentStatus.ts b/frontend/src/utils/agentStatus.ts index 00c3c1e3a..148c687d3 100644 --- a/frontend/src/utils/agentStatus.ts +++ b/frontend/src/utils/agentStatus.ts @@ -2,8 +2,8 @@ import type { AgentDisplayStatus, AgentState } from '../../../shared/types/agent /** * Roll several panel {@link AgentState}s up into one, with precedence - * blocked > working > idle. Returns `unknown` when no agent panel is tracked - * (e.g. a session with only plain-shell panels), so callers can hide the badge. + * blocked > working > idle. Returns `unknown` before any terminal status is + * available. Plain shells participate through generic activity detection. */ export function rollupAgentState(states: Array): AgentState { let sawWorking = false; diff --git a/frontend/src/utils/terminalDock.test.ts b/frontend/src/utils/terminalDock.test.ts new file mode 100644 index 000000000..fe663a424 --- /dev/null +++ b/frontend/src/utils/terminalDock.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import type { TerminalPanelState, ToolPanel } from '../../../shared/types/panels'; +import { getDockTerminalPanel } from './terminalDock'; + +const terminal = (id: string, customState: TerminalPanelState = {}): ToolPanel => ({ + id, sessionId: 'session', type: 'terminal', title: id, + state: { isActive: false, customState }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, +}); + +describe('getDockTerminalPanel', () => { + it('keeps the first plain shell in the dock and leaves additional shells as tabs', () => { + const shell = terminal('shell'); + expect(getDockTerminalPanel([shell, terminal('extra')])).toBe(shell); + }); + + it.each([ + { initialCommand: 'codex --yolo' }, + { initialCommand: 'claude --dangerously-skip-permissions' }, + { initialCommand: 'pnpm dev' }, + { agentType: 'codex' as const }, + { agentType: 'claude' as const }, + { isCliPanel: true }, + ])('never promotes a tool after deleting its shell: %j', state => { + const agent = terminal('agent', state); + const shell = terminal('shell'); + expect(getDockTerminalPanel([agent, shell])).toBe(shell); + expect(getDockTerminalPanel([agent])).toBeUndefined(); + }); + + it('ignores inspector panels and supports legacy shells without custom state', () => { + const shell = { ...terminal('shell'), state: { isActive: false } }; + const inspector: ToolPanel = { ...terminal('files'), type: 'explorer' }; + expect(getDockTerminalPanel([inspector, shell])).toBe(shell); + expect(getDockTerminalPanel([])).toBeUndefined(); + }); +}); diff --git a/frontend/src/utils/terminalDock.ts b/frontend/src/utils/terminalDock.ts new file mode 100644 index 000000000..bc1a49df8 --- /dev/null +++ b/frontend/src/utils/terminalDock.ts @@ -0,0 +1,12 @@ +import type { TerminalPanelState, ToolPanel } from '../../../shared/types/panels'; + +/** Only a plain shell belongs in the dock; launched tools stay in working tabs. */ +export function getDockTerminalPanel(panels: readonly ToolPanel[]): ToolPanel | undefined { + return panels.find(panel => { + if (panel.type !== 'terminal') return false; + // SAFETY: The terminal discriminator determines the custom-state shape. + const state = panel.state.customState as TerminalPanelState | undefined; + // initialCommand is available before the process reports its agent metadata. + return !state?.initialCommand?.trim() && !state?.isCliPanel && !state?.agentType; + }); +} diff --git a/frontend/src/utils/terminalInput.test.ts b/frontend/src/utils/terminalInput.test.ts new file mode 100644 index 000000000..6d831f85a --- /dev/null +++ b/frontend/src/utils/terminalInput.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sendTerminalInput } from './terminalInput'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('sendTerminalInput', () => { + it('forwards input on the terminal input channel', () => { + const invoke = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('window', { electronAPI: { invoke } }); + sendTerminalInput('panel-1', '\x1b[A'); + expect(invoke).toHaveBeenCalledWith('terminal:input', 'panel-1', '\x1b[A'); + }); + + it('handles discarded input so it never becomes an unhandled rejection', async () => { + const error = new Error('Remote Pane disconnected; pending terminal input was discarded'); + vi.stubGlobal('window', { electronAPI: { invoke: vi.fn().mockRejectedValue(error) } }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + try { + sendTerminalInput('panel-1', 'a'); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(unhandled).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith('[Terminal] Input was not delivered:', error); + } finally { + process.off('unhandledRejection', unhandled); + } + }); +}); diff --git a/frontend/src/utils/terminalInput.ts b/frontend/src/utils/terminalInput.ts new file mode 100644 index 000000000..275ce0332 --- /dev/null +++ b/frontend/src/utils/terminalInput.ts @@ -0,0 +1,12 @@ +/** + * Sends keyboard input to a terminal without waiting for delivery. + * + * Remote Pane rejects input it had to discard after a disconnect, a failed + * request, or a timeout. Handle that here so it never reaches the renderer's + * global unhandled-rejection alert, which would open a dialog per keystroke. + */ +export function sendTerminalInput(panelId: string, data: string): void { + void window.electronAPI.invoke('terminal:input', panelId, data).catch(error => { + console.warn('[Terminal] Input was not delivered:', error); + }); +} diff --git a/main/src/daemon/client/remoteInputQueue.test.ts b/main/src/daemon/client/remoteInputQueue.test.ts new file mode 100644 index 000000000..60a067904 --- /dev/null +++ b/main/src/daemon/client/remoteInputQueue.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RemoteInputQueue } from '../../../../shared/remoteInputQueue'; + +afterEach(() => vi.useRealTimers()); + +describe('RemoteInputQueue lifecycle', () => { + it('ignores late completions after cancellation when new input is already pending', async () => { + const finish: Array<() => void> = []; + const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => + new Promise(resolve => finish.push(resolve))); + const queue = new RemoteInputQueue(send); + const old = Promise.allSettled([ + queue.invoke('terminal:input', ['panel', 'old']), + queue.invoke('terminal:input', ['panel', 'discard']), + ]); + queue.cancel(new Error('Disconnected')); + const current = Promise.all([ + queue.invoke('terminal:input', ['panel', 'new']), + queue.invoke('terminal:input', ['panel', 'suffix']), + ]); + finish[0](); + await old; + expect(send).toHaveBeenCalledTimes(2); + finish[1](); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(3)); + expect(send.mock.calls[2]).toEqual(['terminal:input', ['panel', 'suffix'], expect.any(AbortSignal)]); + finish[2](); + await current; + }); + + it('aborts a stuck write and rejects pending input when its deadline expires', async () => { + vi.useFakeTimers(); + const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => new Promise(() => {})); + const queue = new RemoteInputQueue(send); + const completed = Promise.allSettled([ + queue.invoke('terminal:input', ['panel', 'a']), + queue.invoke('terminal:input', ['panel', 'b']), + ]); + await vi.advanceTimersByTimeAsync(10_000); + expect((await completed).every(result => result.status === 'rejected' + && result.reason instanceof Error && result.reason.message.includes('timed out'))).toBe(true); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][2]?.aborted).toBe(true); + }); + + it('ends a combined batch at a bare Escape so the next key is not read as Alt+key', async () => { + const finish: Array<() => void> = []; + const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => + new Promise(resolve => finish.push(resolve))); + const queue = new RemoteInputQueue(send); + // Typed text, then Esc, more text, then Esc, Esc, Up while the first key is in flight. + const completed = Promise.all(['a', 'b', '\x1b', 'c', '\x1b', '\x1b', '\x1b[A'].map(key => + queue.invoke('terminal:input', ['panel', key]))); + const expected = ['a', 'b\x1b', 'c\x1b', '\x1b', '\x1b[A']; + for (const [index, data] of expected.entries()) { + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(index + 1)); + expect(send.mock.calls[index][1]).toEqual(['panel', data]); + finish[index](); + } + await completed; + expect(send).toHaveBeenCalledTimes(expected.length); + }); + + it('bounds combined batches without splitting individual input events', async () => { + const finish: Array<() => void> = []; + const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => + new Promise(resolve => finish.push(resolve))); + const queue = new RemoteInputQueue(send); + const paste = 'x'.repeat(64 * 1024); + const completed = Promise.all([ + queue.invoke('terminal:input', ['panel', 'a']), + queue.invoke('terminal:input', ['panel', paste]), + queue.invoke('terminal:input', ['panel', '\r']), + ]); + finish[0](); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)); + expect(send.mock.calls[1][1]).toEqual(['panel', paste]); + finish[1](); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(3)); + expect(send.mock.calls[2][1]).toEqual(['panel', '\r']); + finish[2](); + await completed; + }); +}); diff --git a/main/src/daemon/client/remotePaneClient.ts b/main/src/daemon/client/remotePaneClient.ts index 7acec8458..1c3563082 100644 --- a/main/src/daemon/client/remotePaneClient.ts +++ b/main/src/daemon/client/remotePaneClient.ts @@ -21,6 +21,7 @@ import type { RemoteDaemonEventEnvelope } from '../../../../shared/types/remoteD import { boundary, decodeBoundary } from '../../../../shared/validation/boundaryDecoder'; import type { BoundarySchema, JsonValue } from '../../../../shared/validation/boundaryDecoder'; import { PaneSseParser } from './sseParser'; +import { RemoteInputQueue } from '../../../../shared/remoteInputQueue'; interface RemoteConnectionStateMetadata { lastSeenAt?: string | null; @@ -135,6 +136,8 @@ export class RemotePaneClient { private consecutiveReconnectFailures = 0; private lastSeenAt: string | null = null; private closedByClient = false; + private readonly inputQueue = new RemoteInputQueue((channel, args, signal) => + this.invokeRequest(channel, args, signal)); constructor( readonly profile: RemotePaneConnectionProfile, @@ -181,6 +184,7 @@ export class RemotePaneClient { async disconnect(): Promise { this.closedByClient = true; + this.inputQueue.cancel(new Error('Remote Pane disconnected; pending terminal input was discarded')); this.clearReconnectTimer(); this.clearHeartbeatStaleTimer(); this.eventParser.reset(); @@ -197,11 +201,16 @@ export class RemotePaneClient { } async invoke(channel: string, args: unknown[]): Promise { + return this.inputQueue.invoke(channel, args); + } + + private async invokeRequest(channel: string, args: unknown[], signal?: AbortSignal): Promise { const endpoint = buildRemoteEndpoint(this.normalizedBaseUrl, 'invoke'); let response: JsonResponse; try { response = await requestJson(endpoint, this.buildRequestOptions(endpoint, { method: 'POST', + signal, headers: { Authorization: `Bearer ${this.profile.token}`, 'Content-Type': 'application/json; charset=utf-8', @@ -209,6 +218,7 @@ export class RemotePaneClient { }, }), JSON.stringify({ channel, args })); } catch (error) { + if (signal?.aborted) throw error; const message = getErrorMessage(error, 'Failed to invoke remote daemon'); this.handleUnexpectedDisconnect(message, true); throw error; @@ -406,6 +416,7 @@ export class RemotePaneClient { } private handleUnexpectedDisconnect(message: string, destroyStream: boolean): void { + this.inputQueue.cancel(new Error(message)); const activeResponse = this.eventResponse; const activeRequest = this.eventRequest; diff --git a/main/src/daemon/client/remoteTerminalInput.test.ts b/main/src/daemon/client/remoteTerminalInput.test.ts new file mode 100644 index 000000000..0b2e02b6e --- /dev/null +++ b/main/src/daemon/client/remoteTerminalInput.test.ts @@ -0,0 +1,213 @@ +import http, { type ServerResponse } from 'http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RemoteDaemonBrowserClient } from '../../../../frontend/src/remote/runtime/remoteDaemonBrowserClient'; +import { boundary, decodeBoundary } from '../../../../shared/validation/boundaryDecoder'; +import { RemotePaneClient } from './remotePaneClient'; + +interface InputRequest { + channel: string; + args: unknown[]; + response: ServerResponse; +} + +const clients: Array = []; +const servers: http.Server[] = []; + +beforeEach(() => { + const storage = new Map(); + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + }); + vi.stubGlobal('navigator', { platform: 'Test' }); +}); + +afterEach(async () => { + await Promise.all(clients.splice(0).map(client => client.disconnect())); + await Promise.all(servers.splice(0).map(server => new Promise(resolve => { + server.close(() => resolve()); + server.closeAllConnections(); + }))); + vi.unstubAllGlobals(); +}); + +describe.each([ + ['desktop', RemotePaneClient], + ['browser', RemoteDaemonBrowserClient], +] as const)('%s remote terminal input', (_name, Client) => { + async function setup() { + const requests: InputRequest[] = []; + let eventResponse: ServerResponse | undefined; + const server = http.createServer(async (request, response) => { + if (request.url?.startsWith('/health')) { + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url?.startsWith('/events')) { + eventResponse = response; + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write(`event: ready\ndata: ${JSON.stringify({ + replay: 'none', resync: 'refetch-state-after-reconnect', timestamp: new Date().toISOString(), + })}\n\n`); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const parsed = decodeBoundary(JSON.parse(Buffer.concat(chunks).toString()), boundary.object({ + channel: boundary.string, + args: boundary.array(boundary.json), + })); + requests.push({ ...parsed, response }); + }); + servers.push(server); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = decodeBoundary(server.address(), boundary.object({ port: boundary.number })); + const client = new Client({ + id: 'input-test', label: 'Input test', token: 'test-token', transport: 'http+sse', + baseUrl: `http://127.0.0.1:${address.port}`, + }); + clients.push(client); + return { client, requests, getEventResponse: () => eventResponse }; + } + + it('keeps fast typing ordered and combines pending keys into the next request', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + client.invoke('terminal:input', ['panel-1', 'c']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(requests.map(request => request.args[1])).toEqual(['a']); + + reply(requests[0]); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].args).toEqual(['panel-1', 'bc']); + reply(requests[1]); + expect(await completed).toEqual([ + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: undefined }, + ]); + }); + + it('preserves Unicode, paste boundaries, and control keys, ending a write at a bare Escape', async () => { + const { client, requests } = await setup(); + const keys = ['a', 'é🙂', '\x1b[200~pasted\ntext\x1b[201~', '\x7f', '\x1b', '\x1b[D', '\t', '\x03', '\r']; + const completed = Promise.allSettled(keys.map(key => client.invoke('terminal:input', ['panel-1', key]))); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + reply(requests[0]); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].args[1]).toBe(keys.slice(1, 5).join('')); + reply(requests[1]); + await vi.waitFor(() => expect(requests).toHaveLength(3)); + expect(requests[2].args[1]).toBe(keys.slice(5).join('')); + reply(requests[2]); + expect((await completed).every(result => result.status === 'fulfilled')).toBe(true); + }); + + it('does not block other panels or ordinary remote commands', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-2', 'b']), + client.invoke('sessions:get-all', []), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(3)); + for (const request of requests) reply(request); + expect((await completed).every(result => result.status === 'fulfilled')).toBe(true); + }); + + it('orders both terminal input channels together and preserves their results', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('panels:send-terminal-input', ['panel-1', 'b']), + client.invoke('panels:send-terminal-input', ['panel-1', 'c']), + client.invoke('terminal:input', ['panel-1', '\r']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + reply(requests[0]); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1]).toMatchObject({ channel: 'panels:send-terminal-input', args: ['panel-1', 'bc'] }); + requests[1].response.end(JSON.stringify({ ok: true, result: { success: true } })); + await vi.waitFor(() => expect(requests).toHaveLength(3)); + expect(requests[2]).toMatchObject({ channel: 'terminal:input', args: ['panel-1', '\r'] }); + reply(requests[2]); + expect(await completed).toEqual([ + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: { success: true } }, + { status: 'fulfilled', value: { success: true } }, + { status: 'fulfilled', value: undefined }, + ]); + }); + + it('discards pending input on an HTTP failure without retrying the write', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + reply(requests[0], 503); + const message = _name === 'browser' + ? 'The remote action may have completed, but its result could not be confirmed. Check the current state before trying again. (Input failed)' + : 'Input failed'; + expect(await completed).toEqual([ + { status: 'rejected', reason: new Error(message) }, + { status: 'rejected', reason: new Error(message) }, + ]); + expect(requests).toHaveLength(1); + }); + + it('does not replay input if the connection drops after the host receives it', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + requests[0].response.destroy(); + expect((await completed).every(result => result.status === 'rejected')).toBe(true); + expect(requests).toHaveLength(1); + }); + + it('cancels input on disconnect and never sends its buffered suffix', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + await client.disconnect(); + expect((await completed).every(result => result.status === 'rejected')).toBe(true); + await vi.waitFor(() => expect(requests[0].response.destroyed).toBe(true)); + expect(requests).toHaveLength(1); + }); + + it('discards queued input when the remote event connection is lost', async () => { + const { client, requests, getEventResponse } = await setup(); + await client.connect(); + await vi.waitFor(() => expect(getEventResponse()).toBeDefined()); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + getEventResponse()?.destroy(); + expect((await completed).every(result => result.status === 'rejected')).toBe(true); + expect(requests).toHaveLength(1); + }); +}); + +function reply(request: InputRequest, status = 200): void { + request.response.writeHead(status, { 'Content-Type': 'application/json' }); + request.response.end(JSON.stringify(status === 200 + ? { ok: true } + : { ok: false, error: { message: 'Input failed' } })); +} diff --git a/main/src/daemon/mobilePushSender.test.ts b/main/src/daemon/mobilePushSender.test.ts index a537a8854..b99b07a46 100644 --- a/main/src/daemon/mobilePushSender.test.ts +++ b/main/src/daemon/mobilePushSender.test.ts @@ -82,6 +82,14 @@ describe('MobilePushSender', () => { expect(Buffer.from(requests[0]?.jwt.split('.')[2] ?? '', 'base64url')).toHaveLength(64); expect(manager.config.host.mobilePush.registrations[0]?.recentEventIds).toHaveLength(2); + for (const reason of ['exit', 'destroyed']) { + await sender.observeStatus({ sessionId: 'pane-1', panelId: 'panel-1', state: 'working', reason: 'working' }); + await sender.observeStatus({ sessionId: 'pane-1', panelId: 'panel-1', state: 'idle', reason }); + expect(manager.config.host.mobilePush.panelStates['panel-1']).toBe('idle'); + expect(manager.config.host.mobilePush.attentionSequence).toBe(2); + expect(requests).toHaveLength(2); + } + await sender.updateControls('client-1', 'ios', 'install-1', { completedEnabled: false, needsInputEnabled: false }); await expect(sender.register('client-1', { ...registration, token: 'rotated-token' })).resolves.toMatchObject({ registration: 'registered', completedEnabled: false, needsInputEnabled: false, diff --git a/main/src/daemon/mobilePushSender.ts b/main/src/daemon/mobilePushSender.ts index 29d0f2f2c..844b885ed 100644 --- a/main/src/daemon/mobilePushSender.ts +++ b/main/src/daemon/mobilePushSender.ts @@ -137,7 +137,8 @@ export class MobilePushSender { const config = this.config(); if (!config.host.mobilePush.registrations.some(item => !item.revokedAt && config.host.clients.some(client => client.id === item.clientId))) return; const previous = config.host.mobilePush.panelStates[event.panelId]; - const kind = event.state === 'blocked' && previous !== 'blocked' + const terminalEnded = event.reason === 'exit' || event.reason === 'destroyed'; + const kind = terminalEnded ? null : event.state === 'blocked' && previous !== 'blocked' ? 'needs-input' : previous === 'working' && event.state === 'idle' ? 'completed' diff --git a/main/src/daemon/remotePwaBrowserRuntime.test.ts b/main/src/daemon/remotePwaBrowserRuntime.test.ts index 48b5e52e9..466fa16ed 100644 --- a/main/src/daemon/remotePwaBrowserRuntime.test.ts +++ b/main/src/daemon/remotePwaBrowserRuntime.test.ts @@ -126,7 +126,7 @@ describe('Remote PWA browser runtime', () => { const fetchMock = vi.fn(async () => new Response('truncated JSON', { status: 200 })); vi.stubGlobal('fetch', fetchMock); - await expect(createClient().invoke('terminal:input')).rejects.toThrow(/may have completed/); + await expect(createClient().invoke('terminal:input', ['panel-1', 'x'])).rejects.toThrow(/may have completed/); expect(fetchMock).toHaveBeenCalledTimes(1); }); diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index 0142b1c81..80d314859 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -110,6 +110,7 @@ const PANEL_CHANNELS = [ 'panels:set-active', 'panels:getActive', 'panels:initialize', + 'panels:agent-statuses', 'panels:checkInitialized', 'panels:emitEvent', 'panels:resize-terminal', diff --git a/main/src/ipc/panels.status.test.ts b/main/src/ipc/panels.status.test.ts new file mode 100644 index 000000000..adf3b566c --- /dev/null +++ b/main/src/ipc/panels.status.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { IpcMain } from 'electron'; +import type { AppServices } from './types'; +import type { Session } from '../types/session'; +import type { ToolPanel } from '../../../shared/types/panels'; +import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; +import { panelManager } from '../services/panelManager'; +import { terminalPanelManager } from '../services/terminalPanelManager'; +import { registerPanelHandlers } from './panels'; + +function partial(value: Partial): Contract { + // SAFETY: Each fixture supplies every member read by the tested handlers. + return value as Contract; +} +const panel = (id: string, sessionId: string): ToolPanel => ({ + id, sessionId, type: 'terminal', title: id, state: { isActive: false }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('panel status snapshot IPC', () => { + it('includes background shells, stopped terminals, and hidden Pane Chat with current monitor states', async () => { + vi.spyOn(panelManager, 'getPanelsForSession').mockImplementation(sessionId => sessionId === PANE_CHAT_SESSION_ID + ? [panel('chat', sessionId)] + : [panel('agent', sessionId), panel('shell', sessionId), panel('stopped', sessionId)]); + vi.spyOn(terminalPanelManager, 'getAgentStatus').mockImplementation(panelId => { + if (panelId === 'agent') return 'working'; + if (panelId === 'chat') return 'blocked'; + return undefined; + }); + vi.spyOn(terminalPanelManager, 'isTerminalInitialized').mockImplementation(panelId => panelId !== 'stopped'); + const services = partial({ sessionManager: partial({ + getAllSessions: () => [partial({ id: 'background', archived: false })], + }) }); + const registry = new PaneCommandRegistry(); + const handle = vi.fn(); + registerPanelHandlers(partial({ handle }), services, registry); + expect(handle).toHaveBeenCalledWith('panels:agent-statuses', expect.any(Function)); + await expect(registry.invoke('panels:agent-statuses')).resolves.toEqual({ success: true, data: [ + { panelId: 'agent', sessionId: 'background', state: 'working', reason: null }, + { panelId: 'shell', sessionId: 'background', state: 'unknown', reason: null }, + { panelId: 'stopped', sessionId: 'background', state: 'unknown', reason: null }, + { panelId: 'chat', sessionId: PANE_CHAT_SESSION_ID, state: 'blocked', reason: null }, + ] }); + }); +}); + +describe('panel deletion IPC', () => { + it('awaits terminal teardown without saving a snapshot before deleting the row', async () => { + vi.spyOn(panelManager, 'getPanel').mockReturnValue(panel('p', 's')); + let release: () => void = () => undefined; + const pending = new Promise(resolve => { release = resolve; }); + const destroy = vi.spyOn(terminalPanelManager, 'destroyTerminal').mockReturnValue(pending); + const remove = vi.spyOn(panelManager, 'deletePanel').mockResolvedValue(); + const registry = new PaneCommandRegistry(); + registerPanelHandlers(partial({ handle: vi.fn() }), partial({}), registry); + const deleting = registry.invoke('panels:delete', 'p'); + expect(destroy).toHaveBeenCalledWith('p', { saveState: false }); + expect(remove).not.toHaveBeenCalled(); + release(); + await expect(deleting).resolves.toEqual({ success: true }); + expect(remove).toHaveBeenCalledWith('p'); + }); +}); diff --git a/main/src/ipc/panels.ts b/main/src/ipc/panels.ts index ba089f690..adb335fcc 100644 --- a/main/src/ipc/panels.ts +++ b/main/src/ipc/panels.ts @@ -12,6 +12,8 @@ import { terminalPanelManager } from '../services/terminalPanelManager'; import { databaseService } from '../services/database'; import { CreatePanelRequest, PanelEventType, SessionPanelLayout, ToolPanel, type PanelLayoutNode } from '../../../shared/types/panels'; import type { AppServices } from './types'; +import type { PanelAgentStatusEvent } from '../../../shared/types/agentStatus'; +import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; import { getAppSubdirectory } from '../utils/appDirectory'; import { sanitizeTerminalOutput } from '../utils/terminalOutputSanitizer'; import { getWSLHome, linuxToUNCPath, posixJoin } from '../utils/wslUtils'; @@ -379,6 +381,7 @@ const DAEMON_PANEL_CHANNELS = [ 'panels:delete', 'panels:update', 'panels:list', + 'panels:agent-statuses', 'panels:set-active', 'panels:getActive', 'panels:get-layout', @@ -426,7 +429,7 @@ export function registerPanelHandlers( // Clean up terminal process if it's a terminal panel const panel = panelManager.getPanel(panelId); if (panel?.type === 'terminal') { - terminalPanelManager.destroyTerminal(panelId); + await terminalPanelManager.destroyTerminal(panelId, { saveState: false }); } await panelManager.deletePanel(panelId); @@ -457,6 +460,28 @@ export function registerPanelHandlers( } }); + // Renderer baseline includes hidden Pane Chat, which the public workspace + // snapshot deliberately omits. Read the same authoritative monitor state. + commandRegistry.register('panels:agent-statuses', () => { + const sessionIds = new Set(services.sessionManager.getAllSessions() + .filter(session => !session.archived) + .map(session => session.id)); + sessionIds.add(PANE_CHAT_SESSION_ID); + const statuses: PanelAgentStatusEvent[] = []; + for (const sessionId of sessionIds) { + for (const panel of panelManager.getPanelsForSession(sessionId)) { + if (panel.type !== 'terminal') continue; + statuses.push({ + panelId: panel.id, + sessionId, + state: terminalPanelManager.getAgentStatus(panel.id) ?? 'unknown', + reason: null, + }); + } + } + return { success: true, data: statuses }; + }); + commandRegistry.register('panels:list', async (sessionId: string) => { try { const panels = panelManager.getPanelsForSession(sessionId); diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index ff3722fb6..2878ddddc 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -393,7 +393,7 @@ export function registerSessionHandlers( for (const panel of panels) { try { if (panel.type === 'terminal') { - terminalPanelManager.destroyTerminal(panel.id); + await terminalPanelManager.destroyTerminal(panel.id); } } catch (panelError) { console.error(`[Session IPC] Failed to cleanup panel ${panel.id} (${panel.type}):`, panelError); diff --git a/main/src/services/agentStatus/agentStatusMonitor.test.ts b/main/src/services/agentStatus/agentStatusMonitor.test.ts index 6de93bae4..41bffe212 100644 --- a/main/src/services/agentStatus/agentStatusMonitor.test.ts +++ b/main/src/services/agentStatus/agentStatusMonitor.test.ts @@ -21,8 +21,8 @@ describe('AgentStatusMonitor', () => { it('publishes working while PTY bytes are flowing', () => { const m = new AgentStatusMonitor(opts); m.register('p', 0); - m.noteActivity('p', 10); - expect(m.update('p', detection({ state: 'idle' }), 20)).toBe('working'); + m.noteActivity('p', 3010); + expect(m.update('p', detection({ state: 'idle' }), 3020)).toBe('working'); expect(m.getState('p')).toBe('working'); }); @@ -53,12 +53,17 @@ describe('AgentStatusMonitor', () => { expect(m.getState('p')).toBe('working'); }); - it('suppresses premature idle during the startup grace window', () => { + it('does not invent work during startup', () => { const m = new AgentStatusMonitor(opts); m.register('p', 0); - // No activity, idle detection, but still inside 3s grace -> not idle yet. - expect(m.update('p', detection({ state: 'idle' }), 500)).toBe('working'); - expect(m.getState('p')).toBe('working'); + // Empty shells and boot banners do not represent a completed task. + expect(m.update('p', detection({ state: 'idle' }), 500)).toBeNull(); + expect(m.getState('p')).toBeUndefined(); + m.noteActivity('p', 600); + m.noteActivity('p', 700); + expect(m.update('p', detection({}), 800)).toBeNull(); + expect(m.getState('p')).toBeUndefined(); + expect(m.update('p', detection({}), 3100)).toBe('idle'); }); it('emits only on change', () => { @@ -69,6 +74,43 @@ describe('AgentStatusMonitor', () => { expect(m.update('p', detection({ state: 'working' }), 4020)).toBeNull(); }); + it('settles immediately on reliable idle evidence and ignores redraw activity', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + expect(m.update('p', detection({ state: 'working', visibleWorking: true }), 10)).toBe('working'); + m.noteActivity('p', 4000); + const idle = detection({ visibleIdle: true, matchedRuleId: 'osc_title_idle' }); + expect(m.update('p', idle, 4010)).toBe('idle'); + m.noteActivity('p', 4020); + m.noteActivity('p', 4030); + expect(m.update('p', idle, 4040)).toBeNull(); + expect(m.update('p', detection({ state: 'working', visibleWorking: true }), 4050)).toBe('working'); + }); + + it('retains activity fallback for real work that starts during startup grace', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + m.noteActivity('p', 100); + expect(m.update('p', detection({ visibleWorking: true }), 110)).toBe('working'); + const weakPrompt = detection({ matchedRuleId: 'live_prompt_box' }); + expect(m.update('p', weakPrompt, 120)).toBeNull(); + m.noteActivity('p', 900); + expect(m.update('p', weakPrompt, 1800)).toBeNull(); + expect(m.update('p', weakPrompt, 1900)).toBe('idle'); + }); + + it('does not let a persistent prompt box complete work or wake on typing', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + const prompt = detection({ matchedRuleId: 'live_prompt_box' }); + expect(m.update('p', prompt, 4000)).toBe('idle'); + m.noteActivity('p', 4010); + m.noteActivity('p', 4020); + expect(m.update('p', prompt, 4030)).toBeNull(); + expect(m.update('p', detection({ visibleWorking: true }), 4040)).toBe('working'); + expect(m.update('p', prompt, 4050)).toBeNull(); + }); + it('ignores unregistered panels', () => { const m = new AgentStatusMonitor(opts); expect(m.update('ghost', detection({ state: 'working' }), 0)).toBeNull(); diff --git a/main/src/services/agentStatus/agentStatusMonitor.ts b/main/src/services/agentStatus/agentStatusMonitor.ts index 5ed4a0de7..db01962c0 100644 --- a/main/src/services/agentStatus/agentStatusMonitor.ts +++ b/main/src/services/agentStatus/agentStatusMonitor.ts @@ -3,15 +3,10 @@ * * Owns per-panel status trackers and arbitrates a published {@link AgentState} * from three signals: the screen/OSC {@link AgentDetectionResult}, recent PTY - * byte-activity (the "working" authority), and elapsed time. It is deliberately - * timer-free and clock-injectable — the caller re-evaluates on PTY output and on - * a short poll, so debounce/grace windows resolve purely from timestamps, which - * keeps the machine fully unit-testable. - * - * Arbitration precedence: a visible blocker wins immediately; otherwise recent - * activity (or a working detection) means working; otherwise idle. PTY activity - * stays authoritative for a measured settle window, and a single trailing chunk - * cannot wake an already-idle panel unless working chrome is visible. + * byte-activity, and elapsed time. It is timer-free and clock-injectable. + * Explicit blockers and working chrome win first, then reliable idle evidence. + * Activity is a fallback when there is no live chrome, or extends existing work + * behind a weak prompt (Claude keeps its composer visible during a turn). */ import type { AgentDetectionResult, AgentState } from '../../../../shared/types/agentStatus'; @@ -19,7 +14,7 @@ import type { AgentDetectionResult, AgentState } from '../../../../shared/types/ export interface AgentStatusMonitorOptions { /** How long PTY activity keeps a panel working before it may settle idle. */ idleSettleMs?: number; - /** Idle is suppressed for this long after a panel registers. */ + /** Ignore unclassified boot output for this long after registration. */ startupGraceMs?: number; } @@ -73,6 +68,10 @@ export class AgentStatusMonitor { const tracker = this.trackers.get(panelId); if (!tracker) return; + // Boot banners and shell prompt setup are not evidence of a task. Explicit + // agent working chrome still takes effect immediately, including at startup. + if (now - tracker.startedAt < this.options.startupGraceMs && tracker.published !== 'working') return; + const startsNewBurst = tracker.lastActivityAt === undefined || now - tracker.lastActivityAt >= this.options.idleSettleMs; tracker.activityChunksInBurst = startsNewBurst ? 1 : tracker.activityChunksInBurst + 1; @@ -94,26 +93,36 @@ export class AgentStatusMonitor { // Agent-owned viewer (transcript/model picker): hold the known state. if (detection.skipStateUpdate) return null; - const { idleSettleMs, startupGraceMs } = this.options; + const { idleSettleMs } = this.options; const recentlyActive = tracker.lastActivityAt !== undefined && now - tracker.lastActivityAt < idleSettleMs; const activityCanPublishWorking = - tracker.published !== 'idle' || tracker.activityChunksInBurst >= 2; + (tracker.published === 'working' || detection.matchedRuleId === null) && + (tracker.published !== 'idle' || tracker.activityChunksInBurst >= 2); + + // A blank boot screen is not yet an idle agent. Keep the initial unknown + // state until live chrome appears or the startup grace expires. + if (now - tracker.startedAt < this.options.startupGraceMs && + detection.matchedRuleId === null && !recentlyActive && + !detection.visibleWorking && !detection.visibleIdle && detection.state !== 'blocked') return null; let candidate: AgentState; if (detection.state === 'blocked') { candidate = 'blocked'; - } else if (detection.visibleWorking || (recentlyActive && activityCanPublishWorking)) { + } else if (detection.visibleWorking) { + candidate = 'working'; + // Visible work is activity evidence too, even before boot output is trusted. + tracker.lastActivityAt = now; + } else if (detection.visibleIdle) { + candidate = 'idle'; + tracker.lastActivityAt = undefined; + tracker.activityChunksInBurst = 0; + } else if (recentlyActive && activityCanPublishWorking) { candidate = 'working'; } else { candidate = 'idle'; } - // Startup grace: a freshly launched agent shouldn't flash idle before it boots. - if (candidate === 'idle' && now - tracker.startedAt < startupGraceMs) { - candidate = tracker.published ?? 'working'; - } - if (tracker.published === candidate) return null; tracker.published = candidate; return candidate; diff --git a/main/src/services/agentStatus/manifests.test.ts b/main/src/services/agentStatus/manifests.test.ts index d4fd41a73..4ac3da63e 100644 --- a/main/src/services/agentStatus/manifests.test.ts +++ b/main/src/services/agentStatus/manifests.test.ts @@ -22,6 +22,34 @@ describe('getManifestForAgent', () => { }); describe('CLAUDE_MANIFEST', () => { + it.each(['◐', '◑', '◒', '◓', '⠙'])('recognizes Claude working title %s', (glyph) => { + expect(detectAgentState(CLAUDE_MANIFEST, screen('', `${glyph} Building`)).state).toBe('working'); + }); + + it.each(['· Flowing…', '✶ Jitterbugging…', '✶ Jitterbugging… (2s · ↓ 10 tokens)'])('recognizes live status %s', (line) => { + expect(detectAgentState(CLAUDE_MANIFEST, screen(`${line}\n────────────\n❯ text\n────────────`)).state).toBe('working'); + }); + + it('does not mistake completed summaries or arbitrary titles for working', () => { + expect(detectAgentState(CLAUDE_MANIFEST, screen('✻ Brewed for 0s · done 10:28 PM', 'Project')).state).toBe('idle'); + }); + + it('prioritizes permission and trust prompts over working titles', () => { + for (const prompt of [ + 'Bash command\nDo you want to proceed?\n❯ 1. Yes\n2. No', + '────────────\nDo you want to proceed?\n❯ 1. Yes\n2. No (esc to cancel)', + 'Quick safety check: Is this a project you created or one you trust?\n❯ No, exit\nYes, I trust this folder\nEnter to confirm · Esc to cancel', + ]) { + expect(detectAgentState(CLAUDE_MANIFEST, screen(prompt, '◐ Building')).state).toBe('blocked'); + } + }); + + it('ignores an answered permission above the current composer', () => { + const text = 'Bash command\nDo you want to proceed?\n❯ 1. Yes\n2. No\nDone.\n────────────\n❯ next task\n────────────'; + expect(detectAgentState(CLAUDE_MANIFEST, screen(text, '✳ Project')).state).toBe('idle'); + expect(detectAgentState(CLAUDE_MANIFEST, screen(text, '◐ Building')).state).toBe('working'); + }); + it('classifies a bash permission prompt as blocked', () => { const s = [ '● I will run a command', @@ -109,6 +137,22 @@ describe('CLAUDE_MANIFEST', () => { }); describe('CODEX_MANIFEST', () => { + it.each([ + 'The script asks [y/n].\n› ', + 'Do you want to continue? Say yes.\n› ', + '› Explain this\nThe documentation says [y/n].\n› next question', + ])('ignores completed prose containing prompt text: %s', (text) => { + expect(detectAgentState(CODEX_MANIFEST, screen(text, 'Codex')).state).toBe('idle'); + }); + + it('recognizes wrapped working chrome', () => { + expect(detectAgentState(CODEX_MANIFEST, screen('• Working (5s • esc to\n interrupt)\n› ')).state).toBe('working'); + }); + + it.each(['q close', 'q to quit'])('holds transcript viewer state with %s', (close) => { + expect(detectAgentState(CODEX_MANIFEST, screen(`↑/↓ to scroll · pgup/pgdn to page · home/end to jump\n${close} · esc to edit prev`)).skipStateUpdate).toBe(true); + }); + it('classifies the Action Required title as blocked', () => { const r = detectAgentState(CODEX_MANIFEST, screen('working on it', 'Action Required · Codex')); expect(r.state).toBe('blocked'); diff --git a/main/src/services/agentStatus/manifests.ts b/main/src/services/agentStatus/manifests.ts index 2123c4684..e67c1964c 100644 --- a/main/src/services/agentStatus/manifests.ts +++ b/main/src/services/agentStatus/manifests.ts @@ -14,8 +14,8 @@ import type { AgentManifest } from './manifestEngine'; -/** Braille spinner glyphs Claude/Codex animate in their OSC title / status line. */ -const SPINNER_TITLE = /^[\u{2800}-\u{28FF}] /u; +/** Observed working-title glyphs, including older braille animation. */ +const SPINNER_TITLE = /^[◐◑◒◓\u{2800}-\u{28FF}] /u; const CODEX_SPINNER = /(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)/u; export const CLAUDE_MANIFEST: AgentManifest = { @@ -24,11 +24,28 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'osc_title_working', state: 'working', - priority: 1100, + priority: 990, region: 'osc_title', visibleWorking: true, regex: [SPINNER_TITLE], }, + { + id: 'folder_trust_prompt', + state: 'blocked', + priority: 1100, + region: 'after_last_horizontal_rule', + visibleBlocker: true, + contains: ['quick safety check:', 'yes, i trust this folder', 'enter to confirm', 'esc to cancel'], + lineRegex: [/^\s*❯?\s*No, exit\s*$/i], + }, + { + id: 'spinner_status_line', + state: 'working', + priority: 974, + region: 'bottom_non_empty_lines(8)', + visibleWorking: true, + lineRegex: [/^\s*[·✶✻✽✢✳]\s+[\p{L} -]+…(?:\s+\([^)]*\))?\s*$/u], + }, { id: 'btw_overlay_working', state: 'working', @@ -73,7 +90,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'live_blocked_form', state: 'blocked', - priority: 980, + priority: 1100, region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['enter to select', 'esc to cancel'], @@ -88,8 +105,8 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'dynamic_workflow_prompt', state: 'blocked', - priority: 980, - region: 'whole_recent', + priority: 1100, + region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['run a dynamic workflow?', 'esc to cancel'], }, @@ -98,7 +115,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { state: 'idle', priority: 950, region: 'prompt_box_body', - visibleIdle: true, + // The composer remains visible mid-turn; it is not completion evidence. lineRegex: [/^\s*❯/], not: [ { contains: ['enter to select'] }, @@ -120,8 +137,8 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'bash_permission_prompt', state: 'blocked', - priority: 850, - region: 'whole_recent', + priority: 1100, + region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['do you want to proceed?'], any: [ @@ -144,7 +161,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'generic_permission_prompt', state: 'blocked', - priority: 840, + priority: 1100, region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['do you want to proceed?', 'esc to cancel'], @@ -180,7 +197,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'osc_title_idle', state: 'idle', - priority: 250, + priority: 960, region: 'osc_title', visibleIdle: true, regex: [/^\u{2733} /u], @@ -220,8 +237,11 @@ export const CODEX_MANIFEST: AgentManifest = { priority: 1000, region: 'after_last_prompt_marker', skipStateUpdate: true, - contains: ['↑/↓ to scroll', 'pgup/pgdn to', 'home/end to jump', 'q to quit'], - any: [{ contains: ['esc to edit prev'] }, { contains: ['esc/← to edit prev'] }], + contains: ['↑/↓ to scroll', 'pgup/pgdn to', 'home/end to jump'], + all: [ + { any: [{ contains: ['q to quit'] }, { contains: ['q close'] }] }, + { any: [{ contains: ['esc to edit prev'] }, { contains: ['esc/← to edit prev'] }] }, + ], }, { id: 'live_strong_blocker', @@ -240,21 +260,17 @@ export const CODEX_MANIFEST: AgentManifest = { id: 'weak_blocker', state: 'blocked', priority: 600, - region: 'whole_recent', - any: [ - { contains: ['[y/n]'] }, - { contains: ['yes (y)'] }, - { contains: ['do you want to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, - { contains: ['would you like to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, - ], + region: 'after_last_prompt_marker', + visibleBlocker: true, + regex: [/(?:^|\n)[^\n]*\?\s*(?:\[y\/n[^\]]*\]|yes \(y\))\s*:?\s*$/i], }, { id: 'screen_working_fallback', state: 'working', priority: 500, - region: 'bottom_non_empty_lines(3)', + region: 'bottom_non_empty_lines(6)', visibleWorking: true, - lineRegex: [/^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$/], + regex: [/^\s*[•◦]\s+Working \([^)]*esc\s+to\s+interrupt\)(?: · [^\n]*)?\s*$/m], not: [{ contains: ['■ Conversation interrupted'] }], }, { diff --git a/main/src/services/paneChatManager.ts b/main/src/services/paneChatManager.ts index 2b625b23b..e8ac0a83c 100644 --- a/main/src/services/paneChatManager.ts +++ b/main/src/services/paneChatManager.ts @@ -118,7 +118,7 @@ export class PaneChatManager { const needsAgentSwitch = existingAgent !== agent; const needsRepair = needsAgentSwitch || (!isInitialized && this.needsLaunchStateRepair(existingPanel, existingAgent)); if (needsAgentSwitch && isInitialized) { - terminalPanelManager.destroyTerminal(existingPanel.id); + await terminalPanelManager.destroyTerminal(existingPanel.id); } if (!isInitialized || needsRepair) { diff --git a/main/src/services/terminalPanelManager.persistence.test.ts b/main/src/services/terminalPanelManager.persistence.test.ts index 3c205594f..760ad371c 100644 --- a/main/src/services/terminalPanelManager.persistence.test.ts +++ b/main/src/services/terminalPanelManager.persistence.test.ts @@ -144,9 +144,9 @@ describe('terminal panel persistence', () => { } }); - afterEach(() => { + afterEach(async () => { for (const manager of managers) { - for (const panelId of manager.getActiveTerminals()) manager.destroyTerminal(panelId); + for (const panelId of manager.getActiveTerminals()) await manager.destroyTerminal(panelId); } panelManagerMock.updatePanel.mockReset(); panelManagerMock.getPanel.mockReset(); @@ -165,6 +165,40 @@ describe('terminal panel persistence', () => { return { manager, handle: ptyHost.latest() }; } + it('keeps a tool panel unknown until its delayed command is injected', async () => { + const panel = makePanel('panel-delayed-command'); + panel.state.customState = { initialCommand: 'codex', agentType: 'codex', isCliPanel: true }; + const { manager, handle } = await startTerminal(panel); + // No shell prompt arrives. The command must wait for the five-second + // fallback, beyond the ordinary three-second monitor startup grace. + await new Promise(resolve => setTimeout(resolve, 3500)); + expect(handle.written.some(data => data.includes('codex'))).toBe(false); + expect(manager.getAgentStatus(panel.id)).toBeUndefined(); + expect(manager.getTerminalSnapshot(panel.id)?.activityStatus).toBe('active'); + await vi.waitFor(() => expect(handle.written.some(data => data.includes('codex'))).toBe(true), { timeout: 2500 }); + expect(manager.getAgentStatus(panel.id)).toBeUndefined(); + handle.emit('\x1b]2;Codex\x07'); + await vi.waitFor(() => expect(manager.getAgentStatus(panel.id)).toBe('idle'), { timeout: 1500 }); + expect(manager.getTerminalSnapshot(panel.id)?.activityStatus).toBe('idle'); + }, 10_000); + + it('ignores the launch shell title until Codex supplies its own status', async () => { + const panel = makePanel('panel-shell-title'); + panel.state.customState = { initialCommand: 'codex', agentType: 'codex', isCliPanel: true }; + const { manager, handle } = await startTerminal(panel); + handle.emit('\x1b]2;user@host: ~/project\x07user@host:~/project$ '); + await vi.waitFor(() => expect(handle.written.some(data => data.includes('codex'))).toBe(true), { timeout: 1500 }); + // Allow status polling to run within the startup grace window. + await new Promise(resolve => setTimeout(resolve, 800)); + expect(manager.getAgentStatus(panel.id)).toBeUndefined(); + expect(manager.getTerminalSnapshot(panel.id)?.activityStatus).toBe('active'); + + handle.emit('\x1b]2;Custom task title\x07'); + await vi.waitFor(() => expect(manager.getAgentStatus(panel.id)).toBe('idle'), { timeout: 1500 }); + handle.emit('\x1b]2;⠙ Custom task title\x07'); + await vi.waitFor(() => expect(manager.getAgentStatus(panel.id)).toBe('working'), { timeout: 1500 }); + }); + it('streams 50 MB of newline-free alternate-screen frames without growing the persisted state', async () => { const panel = makePanel('panel-frames'); const { manager, handle } = await startTerminal(panel); @@ -230,7 +264,7 @@ describe('terminal panel persistence', () => { // routes the same write into panel_buffers. expect(lastPersisted).not.toBeNull(); expect(databaseService.updatePanel(panel.id, { state: lastPersisted ?? { isActive: false } })).toBe(true); - first.destroyTerminal(panel.id); + await first.destroyTerminal(panel.id); const second = new TerminalPanelManager(inProcessEmulatorHost); managers.push(second); diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts new file mode 100644 index 000000000..5da3f797a --- /dev/null +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -0,0 +1,472 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TerminalPanelManager } from './terminalPanelManager'; +import { inProcessEmulatorHost } from '../test/inProcessEmulatorHost'; +import { AgentStatusMonitor } from './agentStatus/agentStatusMonitor'; +import { WorkspaceJournal } from './workspaceJournal'; +import { resetPaneRuntimeForTests, setPaneRuntime } from '../core/runtime'; +import type { PaneEventArgument } from '../core/eventSink'; +import type { ToolPanel } from '../../../shared/types/panels'; +import { createFlowControlRecord, disposeFlowControlRecord } from '../ptyHost/flowControl'; +import { panelManager } from '../test/setup'; +import { formatWaitResult } from '../../../packages/runpane/src/watchLines'; + +function createTerminal(agentType: 'claude' | 'codex' | undefined = 'codex') { + let onData: (data: string) => void = () => undefined; + let onExit: (exit: { exitCode: number; signal?: number }) => void = () => undefined; + const terminal = { + panelId: 'p', sessionId: 's', agentType, pendingInitialCommand: false, + pty: { + onData: (listener: typeof onData) => { onData = listener; }, + onExit: (listener: typeof onExit) => { onExit = listener; }, + write: vi.fn(), kill: vi.fn(), cols: 80, rows: 24, pid: process.pid, + }, + screenEmulator: inProcessEmulatorHost().createEmulator(80, 24), + scrollbackBuffer: '', alternateScreenBuffer: '', commandHistory: [], + currentCommand: '', lastActivity: new Date(), outputGeneration: 0, + flowControl: createFlowControlRecord(), outputBuffer: '', + // SAFETY: PTY output handlers assign timeout handles; fixtures start without one. + outputFlushTimer: null as ReturnType | null, + isVisible: true, isAlternateScreen: false, inSyncBlock: false, + filterInAltScreen: false, agentSessionScrapeBuffer: '', + }; + return { terminal, data: (data: string) => onData(data), exit: (exit: { exitCode: number; signal?: number } = { exitCode: 0 }) => onExit(exit) }; +} + +type TerminalFixture = ReturnType; +interface StatusAccess { + terminals: Map; + agentStatusMonitor: AgentStatusMonitor; + setupTerminalHandlers(terminal: TerminalFixture['terminal']): void; + pollAgentStatus(): void; + sendInitialInputOnce(panelId: string): void; + getProcessCwd(pid: number): Promise; +} + +let manager: TerminalPanelManager; +let access: StatusAccess; +let journal: WorkspaceJournal; +let fixtures: TerminalFixture[]; +let events: Array<{ channel: string; payload: PaneEventArgument }>; + +function attach(agentType?: 'claude' | 'codex', startedAt = 0) { + const fixture = createTerminal(agentType); + fixtures.push(fixture); + access.terminals.set('p', fixture.terminal); + access.agentStatusMonitor.register('p', startedAt); + access.setupTerminalHandlers(fixture.terminal); + return fixture; +} + +async function pollAgentStatus() { + // Production polls the worker's pushed cache; drain writes over the real + // emulator protocol so each assertion observes the output just emitted. + await Promise.all([...access.terminals.values()].map(terminal => terminal.screenEmulator.refresh())); + access.pollAgentStatus(); +} + +beforeEach(() => { + // xterm writes need real timers; only the status clock is controlled. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(20_000); + fixtures = []; + events = []; + manager = new TerminalPanelManager(); + // SAFETY: This seam mirrors the private members used by these PTY fixtures. + access = manager as StatusAccess; + journal = new WorkspaceJournal({ + resolvePane: paneId => ({ paneId, paneName: 'Pane' }), + resolvePanel: panelId => ({ panelId, paneId: 's', isCliPanel: true }), + }); + setPaneRuntime({ eventSink: { send(channel, payload) { + events.push({ channel, payload }); + journal.send(channel, payload); + } } }); + panelManager.emitPanelEvent.mockImplementation((panelId, type, data) => { + journal.send('panel:event', { type, source: { panelId, sessionId: 's' }, data }); + }); +}); + +afterEach(() => { + for (const { terminal } of fixtures) { + if (terminal.outputFlushTimer) clearTimeout(terminal.outputFlushTimer); + terminal.screenEmulator.dispose(); + disposeFlowControlRecord(terminal.flowControl); + } + journal.dispose(); + resetPaneRuntimeForTests(); + vi.useRealTimers(); + vi.restoreAllMocks(); + panelManager.getPanel.mockReset(); + panelManager.updatePanel.mockReset(); +}); + +describe('terminal status events', () => { + it('keeps idle waits pending during boot and delayed command injection', async () => { + const fixture = attach('codex', 20_000); + vi.setSystemTime(20_500); + await pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBeUndefined(); + expect(manager.getTerminalSnapshot('p')?.activityStatus).toBe('active'); + fixture.terminal.pendingInitialCommand = true; + vi.setSystemTime(24_500); + await pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBeUndefined(); + expect(manager.getTerminalSnapshot('p')?.activityStatus).toBe('active'); + fixture.terminal.pendingInitialCommand = false; + fixture.data('\x1b]2;Codex\x07'); + await pollAgentStatus(); + expect(manager.getTerminalSnapshot('p')?.activityStatus).toBe('idle'); + expect(journal.readAfter(0).entries).toEqual([]); + }); + + it('publishes consistent idle status on exit and deduplicates repeated callbacks', async () => { + const fixture = attach('codex'); + fixture.data('\x1b]2;⠙ Codex\x07'); + await pollAgentStatus(); + fixture.exit(); + fixture.exit(); + expect(events.filter(event => event.channel === 'panel:activityStatus').at(-1)?.payload).toMatchObject({ status: 'idle' }); + expect(events.filter(event => event.channel === 'terminal:exited')).toHaveLength(1); + expect(manager.getAgentStatus('p')).toBeUndefined(); + expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['agent.busy', 'panel.exited']); + }); + + it('retires destroyed terminals before old exit and data callbacks can affect a replacement', async () => { + vi.spyOn(manager, 'saveTerminalState').mockResolvedValue(); + const old = attach('codex'); + old.data('\x1b]2;⠙ Codex\x07'); + await pollAgentStatus(); + await manager.destroyTerminal('p'); + expect(events.filter(event => event.channel === 'panel:agentStatus').at(-1)?.payload).toMatchObject({ state: 'idle', reason: 'destroyed' }); + expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['agent.busy', 'panel.exited']); + expect(formatWaitResult({ epoch: journal.epoch, ...journal.readAfter(0) }, 'lines').some(line => line.startsWith('READY'))).toBe(false); + const replacement = attach('codex'); + replacement.data('\x1b]2;⠙ Codex\x07'); + await pollAgentStatus(); + const count = events.length; + old.exit(); + old.data('old output'); + expect(events).toHaveLength(count); + expect(manager.isTerminalInitialized('p')).toBe(true); + expect(manager.getAgentStatus('p')).toBe('working'); + replacement.exit(); + expect(journal.readAfter(0).entries.filter(entry => entry.kind === 'panel.exited')).toHaveLength(2); + }); + + it('skips snapshot persistence when the panel is being deleted', async () => { + const fixture = attach('codex'); + const save = vi.spyOn(manager, 'saveTerminalState'); + await manager.destroyTerminal('p', { saveState: false }); + expect(save).not.toHaveBeenCalled(); + expect(panelManager.updatePanel).not.toHaveBeenCalled(); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + }); + + it.each([ + { label: 'live PTY', exit: undefined }, + { label: 'successful exit', exit: { exitCode: 0 } }, + { label: 'signaled exit', exit: { exitCode: 17, signal: 9 } }, + ])('drains and saves before disposal, preserving an exit during teardown ($label)', async ({ exit }) => { + const fixture = attach('codex'); + panelManager.getPanel.mockReturnValue({ + id: 'p', sessionId: 's', type: 'terminal', title: 'Codex', + state: { isActive: true, customState: { cwd: '/old' } }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, + }); + vi.spyOn(access, 'getProcessCwd').mockResolvedValue('/live'); + fixture.data('last output before archive'); + const destroying = manager.destroyTerminal('p'); + expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); + expect(manager.destroyTerminal('p')).toBe(destroying); + // Closing callbacks cannot dispose the model before queued writes drain. + if (exit) fixture.exit(exit); + fixture.data('output after teardown began'); + manager.writeToTerminal('p', 'late input'); + expect(fixture.terminal.pty.write).not.toHaveBeenCalled(); + await destroying; + expect(panelManager.updatePanel).toHaveBeenCalledWith('p', { state: expect.objectContaining({ + customState: expect.objectContaining({ cwd: '/live', scrollbackBuffer: expect.stringContaining('last output before archive') }), + }) }); + if (exit) expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); + else expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(journal.readAfter(0).entries).toMatchObject([{ + kind: 'panel.exited', exitCode: exit?.exitCode, + reason: exit?.signal === undefined ? 'terminal:exit' : `signal:${exit.signal}`, + }]); + fixture.exit(); + expect(journal.readAfter(0).entries).toHaveLength(1); + }); + + it.each(['cwd read', 'worker snapshot'])('does not persist or retire a replacement during teardown %s', async phase => { + const old = attach('codex'); + panelManager.getPanel.mockReturnValue({ + id: 'p', sessionId: 's', type: 'terminal', title: 'Codex', + state: { isActive: true }, metadata: { createdAt: '', lastActiveAt: '', position: 0 }, + }); + let release: () => void = () => undefined; + const pending = new Promise(resolve => { release = resolve; }); + vi.spyOn(access, 'getProcessCwd').mockImplementation(async () => { + if (phase === 'cwd read') await pending; + return '/live'; + }); + const restoreSnapshot = old.terminal.screenEmulator.restoreSnapshot.bind(old.terminal.screenEmulator); + let snapshotStarted: () => void = () => undefined; + const snapshotRead = new Promise(resolve => { snapshotStarted = resolve; }); + if (phase === 'worker snapshot') { + vi.spyOn(old.terminal.screenEmulator, 'restoreSnapshot').mockImplementation(async () => { + snapshotStarted(); + await pending; + return restoreSnapshot(); + }); + } + const destroying = manager.destroyTerminal('p'); + if (phase === 'worker snapshot') await snapshotRead; + const replacement = attach('codex'); + replacement.data('\x1b]2;⠙ Codex\x07'); + release(); + await destroying; + await pollAgentStatus(); + expect(panelManager.updatePanel).not.toHaveBeenCalled(); + expect(replacement.terminal.pty.kill).not.toHaveBeenCalled(); + expect(manager.getAgentStatus('p')).toBe('working'); + }); + + it('still retires and kills the terminal when saving fails', async () => { + const fixture = attach('codex'); + vi.spyOn(manager, 'saveTerminalState').mockRejectedValue(new Error('persistence failed')); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await manager.destroyTerminal('p'); + expect(error).toHaveBeenCalled(); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + }); + + it.each(['panel:agentStatus', 'terminal:output'])('cleans up even when %s delivery fails during teardown', async channel => { + const fixture = attach('codex'); + if (channel === 'terminal:output') fixture.data('pending output'); + const dispose = vi.spyOn(fixture.terminal.screenEmulator, 'dispose'); + setPaneRuntime({ eventSink: { send(sentChannel) { + if (sentChannel === channel) throw new Error('event delivery failed'); + } } }); + await expect(manager.destroyTerminal('p', { saveState: false })).rejects.toThrow('event delivery failed'); + expect(dispose).toHaveBeenCalledOnce(); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + expect(access.agentStatusMonitor.isTracked('p')).toBe(false); + await manager.destroyTerminal('p'); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + }); + + it('kills the PTY even when emulator disposal fails', async () => { + const fixture = attach('codex'); + vi.spyOn(fixture.terminal.screenEmulator, 'dispose').mockImplementationOnce(() => { throw new Error('dispose failed'); }); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await manager.destroyTerminal('p', { saveState: false }); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + }); + + it('still kills a WSL terminal when its graceful exit write fails', async () => { + vi.useRealTimers(); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + const fixture = attach('codex'); + Object.assign(fixture.terminal, { isWSL: true }); + fixture.terminal.pty.write.mockImplementation(() => { throw new Error('exit write failed'); }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await manager.destroyTerminal('p', { saveState: false }); + expect(manager.isTerminalInitialized('p')).toBe(false); + expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(500); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + }); + + it('disposes an exited terminal even when status delivery fails', () => { + const fixture = attach('codex'); + const dispose = vi.spyOn(fixture.terminal.screenEmulator, 'dispose'); + setPaneRuntime({ eventSink: { send(channel) { + if (channel === 'panel:agentStatus') throw new Error('event delivery failed'); + } } }); + expect(() => fixture.exit()).toThrow('event delivery failed'); + expect(dispose).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); + }); + + it('polls only the replacement after the old worker cache changes', async () => { + const old = attach('codex'); + const replacement = attach('codex'); + old.terminal.screenEmulator.write('\x1b]2;Codex\x07'); + replacement.data('\x1b]2;⠙ Codex\x07'); + await Promise.all([old.terminal.screenEmulator.refresh(), replacement.terminal.screenEmulator.refresh()]); + access.pollAgentStatus(); + expect(events.filter(event => event.channel === 'panel:agentStatus').map(event => event.payload)).toEqual([ + { panelId: 'p', sessionId: 's', state: 'working', reason: 'osc_title_working' }, + ]); + }); + + it.each(['deleted', 'replaced'] as const)('does not persist a delayed worker snapshot after its panel is %s', async action => { + const fixture = attach('codex'); + const panel = { + id: 'p', sessionId: 's', type: 'terminal' as const, title: 'Codex', + state: { isActive: true }, metadata: { createdAt: '', lastActiveAt: '', position: 0 }, + }; + panelManager.getPanel.mockReturnValue(panel); + vi.spyOn(access, 'getProcessCwd').mockResolvedValue('/live'); + fixture.data('old terminal output'); + const restoreSnapshot = fixture.terminal.screenEmulator.restoreSnapshot.bind(fixture.terminal.screenEmulator); + let release: () => void = () => undefined; + const pending = new Promise(resolve => { release = resolve; }); + let snapshotStarted: () => void = () => undefined; + const snapshotRead = new Promise(resolve => { snapshotStarted = resolve; }); + vi.spyOn(fixture.terminal.screenEmulator, 'restoreSnapshot').mockImplementation(async () => { + snapshotStarted(); + await pending; + return restoreSnapshot(); + }); + const saving = manager.saveTerminalState('p'); + await snapshotRead; + panelManager.getPanel.mockReturnValue(action === 'deleted' ? undefined : { ...panel, state: { isActive: false } }); + release(); + await saving; + expect(panelManager.updatePanel).not.toHaveBeenCalled(); + expect(panel.state).toEqual({ isActive: true }); + }); + + it('preserves panel state updates made while the worker snapshot is pending', async () => { + const fixture = attach('codex'); + const panel: ToolPanel = { + id: 'p', sessionId: 's', type: 'terminal', title: 'Codex', + state: { isActive: true, customState: { initialInput: 'task' } }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, + }; + panelManager.getPanel.mockReturnValue(panel); + vi.spyOn(access, 'getProcessCwd').mockResolvedValue('/live'); + fixture.data('terminal output to persist'); + const restoreSnapshot = fixture.terminal.screenEmulator.restoreSnapshot.bind(fixture.terminal.screenEmulator); + let release: () => void = () => undefined; + const pending = new Promise(resolve => { release = resolve; }); + let snapshotStarted: () => void = () => undefined; + const snapshotRead = new Promise(resolve => { snapshotStarted = resolve; }); + vi.spyOn(fixture.terminal.screenEmulator, 'restoreSnapshot').mockImplementation(async () => { + snapshotStarted(); + await pending; + return restoreSnapshot(); + }); + const saving = manager.saveTerminalState('p'); + await snapshotRead; + // PanelManager replaces state while retaining the same ToolPanel object. + panel.state = { + isActive: false, + customState: { initialInput: 'task', initialInputSentAt: '2026-09-24T00:00:00.000Z', dimensions: { cols: 120, rows: 40 } }, + }; + release(); + await saving; + expect(panelManager.updatePanel).toHaveBeenCalledWith('p', { state: expect.objectContaining({ + isActive: false, + customState: expect.objectContaining({ + initialInput: 'task', initialInputSentAt: '2026-09-24T00:00:00.000Z', dimensions: { cols: 120, rows: 40 }, + scrollbackBuffer: expect.stringContaining('terminal output to persist'), + }), + }) }); + }); + + it('discards a terminal state read after the terminal was replaced', async () => { + const old = attach('codex'); + old.data('old terminal output'); + const restoreSnapshot = old.terminal.screenEmulator.restoreSnapshot.bind(old.terminal.screenEmulator); + let release: () => void = () => undefined; + const pending = new Promise(resolve => { release = resolve; }); + vi.spyOn(old.terminal.screenEmulator, 'restoreSnapshot').mockImplementation(async () => { + await pending; + return restoreSnapshot(); + }); + const reading = manager.getTerminalState('p'); + const replacement = attach('codex'); + replacement.data('replacement output'); + release(); + expect(await reading).toBeNull(); + const state = await manager.getTerminalState('p'); + expect(state?.scrollbackBuffer).toContain('replacement output'); + expect(state?.scrollbackBuffer).not.toContain('old terminal output'); + }); + + it.each(['persistence', 'submit delay'])('does not send an old initial prompt after replacement during %s', async phase => { + vi.useRealTimers(); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.setSystemTime(20_000); + const old = attach('codex'); + let release: () => void = () => undefined; + const persisted = new Promise(resolve => { release = resolve; }); + panelManager.getPanel.mockReturnValue({ + id: 'p', sessionId: 's', type: 'terminal', title: 'Codex', + state: { isActive: true, customState: { initialInput: 'old task', initialInputSubmitStrategy: 'codex-ctrl-enter' } }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, + }); + panelManager.updatePanel.mockReturnValue(persisted); + access.sendInitialInputOnce('p'); + if (phase === 'submit delay') { + release(); + await vi.advanceTimersByTimeAsync(0); + expect(old.terminal.pty.write).toHaveBeenCalledWith('old task'); + } + const replacement = attach('codex'); + release(); + await vi.advanceTimersByTimeAsync(500); + expect(replacement.terminal.pty.write).not.toHaveBeenCalled(); + panelManager.getPanel.mockReset(); + panelManager.updatePanel.mockReset(); + }); + + it('retains tracking after a write error until actual exit evidence arrives', async () => { + const fixture = attach('codex'); + fixture.data('\x1b]2;⠙ Codex\x07'); + await pollAgentStatus(); + fixture.terminal.pty.write.mockImplementation(() => { throw new Error('write failed'); }); + manager.writeToTerminal('p', 'hello'); + expect(manager.isTerminalInitialized('p')).toBe(true); + fixture.data('\x1b]2;Codex\x07'); + await pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBe('idle'); + fixture.exit(); + expect(manager.getAgentStatus('p')).toBeUndefined(); + }); + + it.each(['claude', 'codex'] as const)('does not publish work or completion from %s typing and cursor redraws', async agent => { + const fixture = attach(agent); + const title = agent === 'claude' ? '✳ Project' : 'Project'; + fixture.data(`\x1b]2;${title}\x07Finished.\r\n› `); + await pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBe('idle'); + fixture.data('\x1b[?25l'); + fixture.data('\x1b[?25h'); + fixture.data('draft input'); + await pollAgentStatus(); + vi.setSystemTime(40_000); + await pollAgentStatus(); + expect(events.filter(event => event.channel === 'panel:agentStatus')).toEqual([ + { channel: 'panel:agentStatus', payload: { panelId: 'p', sessionId: 's', state: 'idle', reason: 'osc_title_idle' } }, + ]); + expect(journal.readAfter(0).entries).toEqual([]); + expect(formatWaitResult({ epoch: journal.epoch, ...journal.readAfter(0) }, 'lines')).toEqual([]); + }); + + it('reports immediate real work and completion with coherent reasons', async () => { + const fixture = attach('claude', 20_000); + fixture.data('\x1b]2;✳ Project\x07'); + await pollAgentStatus(); + manager.writeToTerminal('p', 'go\r'); + fixture.data('\x1b]2;◐ Building\x07'); + await pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBe('working'); + fixture.data('\x1b]2;✳ Project\x07Done.'); + await pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBe('idle'); + expect(journal.readAfter(0).entries.map(entry => [entry.kind, entry.reason])).toEqual([ + ['agent.busy', 'osc_title_working'], ['agent.ready', 'osc_title_idle'], + ]); + expect(formatWaitResult({ epoch: journal.epoch, ...journal.readAfter(0) }, 'lines')).toEqual([ + 'BUSY Pane pane s panel p', 'READY Pane pane s panel p', + ]); + }); +}); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 1f27512f7..7b1f056c5 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -197,6 +197,11 @@ interface TerminalProcess { screenEmulator?: RemoteTerminalEmulator; commandHistory: string[]; currentCommand: string; + /** The requested tool has not yet been injected into its shell. */ + pendingInitialCommand?: boolean; + /** Coalesces teardown callers and prevents callbacks from reviving a closing terminal. */ + destroying?: Promise; + exitDuringDestroy?: { exitCode: number; signal?: number }; lastActivity: Date; lastOutputAt?: Date; outputGeneration: number; @@ -463,13 +468,16 @@ export class TerminalPanelManager extends EventEmitter { } private sendInitialInputOnce(panelId: string): void { + const terminal = this.terminals.get(panelId); + if (!terminal || terminal.destroying) return; this.markInitialInputSent(panelId).then((delivery) => { - if (!delivery) { + if (!delivery || this.terminals.get(panelId) !== terminal || terminal.destroying) { return; } this.writeInitialInput(panelId, delivery.input, delivery.submitStrategy); }).catch((error) => { + if (this.terminals.get(panelId) !== terminal || terminal.destroying) return; console.warn(`[TerminalPanelManager] Failed to send initial input for panel ${panelId}:`, error); this.markInitialInputError(panelId, error instanceof Error ? error.message : String(error)).catch(() => {}); }); @@ -494,9 +502,12 @@ export class TerminalPanelManager extends EventEmitter { input: string, submitStrategy: NonNullable, ): void { + const terminal = this.terminals.get(panelId); + if (!terminal || terminal.destroying) return; if (submitStrategy === 'codex-ctrl-enter') { this.writeToTerminal(panelId, input); setTimeout(() => { + if (this.terminals.get(panelId) !== terminal || terminal.destroying) return; this.writeToTerminal(panelId, '\x1b[13;5u\r'); }, 500); return; @@ -1007,6 +1018,12 @@ export class TerminalPanelManager extends EventEmitter { }); } + // Another initialization can finish while the ptyHost spawn is awaited. + if (this.terminals.has(panel.id)) { + ptyProcess.kill(); + return; + } + // Create terminal process object const terminalProcess: TerminalProcess = { pty: ptyProcess, @@ -1037,10 +1054,15 @@ export class TerminalPanelManager extends EventEmitter { agentSessionScrapeBuffer: '' }; + terminalProcess.pendingInitialCommand = Boolean(terminalCustomState(panel.state).initialCommand); + // Store in map (ptyHost path: pid is already populated on the shim). this.terminals.set(panel.id, terminalProcess); - // Begin at-a-glance status detection for AI/CLI agent panels. + // Install lifetime guards before any launch-state persistence can yield. + this.setupTerminalHandlers(terminalProcess); + + // Begin at-a-glance status detection for every terminal panel. this.registerAgentStatusPanel(terminalProcess); // Tell the renderer which `ptyId` backs this panel so `TerminalPanel.tsx` @@ -1059,8 +1081,7 @@ export class TerminalPanelManager extends EventEmitter { const initialCommand = existingState?.initialCommand; const initialInput = existingState?.initialInput; - // If we have an initial command, set up the prompt detection listener BEFORE - // setupTerminalHandlers so we don't miss early shell output. + // Wait for the shell prompt before sending an initial command. let commandToRun: string | undefined; if (initialCommand) { const launchResolution = this.resolveCliLaunchCommand(panel.id, initialCommand, existingState || {}, shellType); @@ -1074,6 +1095,8 @@ export class TerminalPanelManager extends EventEmitter { }); } + if (this.terminals.get(panel.id) !== terminalProcess || terminalProcess.destroying) return; + // Detect the interactive prompt before injecting the command. // Previous approaches (fixed 500ms delay, then fire-on-any-data + 300ms) failed // because shell init output (MINGW banner, .bashrc) fires before the prompt is ready. @@ -1081,6 +1104,14 @@ export class TerminalPanelManager extends EventEmitter { // so banner lines ending with % or > don't trigger a false positive. const panelId = panel.id; const injectCommand = () => { + if (this.terminals.get(panelId) !== terminalProcess || terminalProcess.destroying) return; + if (isCliCommand) { + // Clear the shell's title in the headless model before trusting agent + // status. Queued shell writes drain before this reset and CLI output. + terminalProcess.screenEmulator?.write('\x1b]2;\x07'); + } + terminalProcess.pendingInitialCommand = false; + this.agentStatusMonitor.register(panelId, Date.now()); this.writeToTerminal(panelId, commandToRun! + '\r'); // For CLI tool terminals, signal the frontend when the CLI responds @@ -1090,7 +1121,7 @@ export class TerminalPanelManager extends EventEmitter { let onCliOutput: ReturnType | null = null; const signalCliReady = () => { - if (cliReadySignaled) return; + if (cliReadySignaled || this.terminals.get(panelId) !== terminalProcess || terminalProcess.destroying) return; cliReadySignaled = true; if (onCliOutput) onCliOutput.dispose(); @@ -1127,18 +1158,19 @@ export class TerminalPanelManager extends EventEmitter { // Safety timeout: dismiss after 10s regardless setTimeout(signalCliReady, 10000); } else if (initialInput) { - setTimeout(() => this.sendInitialInputOnce(panelId), 1000); + setTimeout(() => { + if (this.terminals.get(panelId) === terminalProcess && !terminalProcess.destroying) this.sendInitialInputOnce(panelId); + }, 1000); } }; this.scheduleAfterShellPrompt(ptyProcess, injectCommand); } else if (initialInput) { - setTimeout(() => this.sendInitialInputOnce(panel.id), 1000); + setTimeout(() => { + if (this.terminals.get(panel.id) === terminalProcess && !terminalProcess.destroying) this.sendInitialInputOnce(panel.id); + }, 1000); } - // Set up event handlers - this.setupTerminalHandlers(terminalProcess); - // Update panel state const state = panel.state; state.customState = { @@ -1164,13 +1196,14 @@ export class TerminalPanelManager extends EventEmitter { private setupTerminalHandlers(terminal: TerminalProcess): void { // Handle terminal output terminal.pty.onData((data: string) => { + if (this.terminals.get(terminal.panelId) !== terminal || terminal.destroying) return; // Update last activity const outputAt = new Date(); terminal.lastActivity = outputAt; terminal.lastOutputAt = outputAt; terminal.outputGeneration += 1; - // Feed PTY activity to the agent-status monitor (the "working" authority). + // Feed byte activity as a fallback behind explicit screen/title evidence. this.agentStatusMonitor.noteActivity(terminal.panelId, outputAt.getTime()); // Detect alternate screen buffer enter/exit for universal TUI detection @@ -1266,28 +1299,17 @@ export class TerminalPanelManager extends EventEmitter { // Handle terminal exit terminal.pty.onExit((exitCode: { exitCode: number; signal?: number }) => { - // A finished agent is "done": settle its status to idle and stop tracking. - if (this.agentStatusMonitor.isTracked(terminal.panelId)) { - this.emitAgentStatus(terminal, 'idle', 'exit'); - this.agentStatusMonitor.unregister(terminal.panelId); - this.maybeStopAgentStatusPoll(); + if (this.terminals.get(terminal.panelId) !== terminal) return; + if (terminal.destroying) { + // The save still owns the emulator; retain exit details until it drains. + terminal.exitDuringDestroy ??= exitCode; + return; + } + try { + this.retireTerminal(terminal, exitCode); + } finally { + terminal.screenEmulator?.dispose(); } - - // Emit exit event - panelManager.emitPanelEvent( - terminal.panelId, - 'terminal:exit', - { - exitCode: exitCode.exitCode, - signal: exitCode.signal, - timestamp: new Date().toISOString() - } - ); - - // Clean up - terminal.screenEmulator?.dispose(); - this.terminals.delete(terminal.panelId); - this.visibleViewersByPanel.delete(terminal.panelId); // Notify frontend (include signal for crash detection) this.sendRendererEvent('terminal:exited', { @@ -1349,13 +1371,12 @@ export class TerminalPanelManager extends EventEmitter { return; } + if (terminal.destroying) return; try { terminal.pty.write(data); } catch (err) { - // PTY may have exited between the map lookup and the write call + // A write failure alone does not prove process death; onExit owns cleanup. console.warn(`[TerminalPanelManager] Failed to write to terminal ${panelId}:`, err); - this.terminals.delete(panelId); - this.visibleViewersByPanel.delete(panelId); return; } terminal.lastActivity = new Date(); @@ -1368,6 +1389,7 @@ export class TerminalPanelManager extends EventEmitter { options: { force?: boolean } = {}, ): Promise { const terminal = this.terminals.get(panelId); + if (terminal?.destroying) return; if (!terminal) { console.warn(`[TerminalPanelManager] Terminal ${panelId} not found for resize`); return; @@ -1404,12 +1426,14 @@ export class TerminalPanelManager extends EventEmitter { // Back-to-back TIOCSWINSZ calls can collapse into a single pending signal. await new Promise(resolve => setTimeout(resolve, FORCED_REDRAW_TRANSITION_MS)); } + if (this.terminals.get(panelId) !== terminal || terminal.destroying) return; terminal.pty.resize(cols, rows); terminal.screenEmulator?.resize(cols, rows); if (options.force) { // Let the final application redraw reach our output batch before the // renderer removes its activation mask. await new Promise(resolve => setTimeout(resolve, FORCED_REDRAW_SETTLE_MS)); + if (this.terminals.get(panelId) !== terminal || terminal.destroying) return; this.flushOutputBuffer(terminal); } } catch (err) { @@ -1456,9 +1480,16 @@ export class TerminalPanelManager extends EventEmitter { console.warn(`[TerminalPanelManager] Could not get CWD for terminal ${panelId}:`, error); } - // Save state to panel - const state = panel.state; + if (this.terminals.get(panelId) !== terminal || panelManager.getPanel(panelId) !== panel) return; + await this.persistTerminalState(terminal, panel, cwd); + } + + private async persistTerminalState(terminal: TerminalProcess, panel: ToolPanel, cwd: string): Promise { + const panelId = terminal.panelId; const restore = await terminal.screenEmulator?.restoreSnapshot(); + // The worker read can finish after this terminal or panel was replaced. + if (this.terminals.get(panelId) !== terminal || panelManager.getPanel(panelId) !== panel) return; + const state = panel.state; const savedIsAlternateScreen = restore?.isAlternateScreen ?? terminal.isAlternateScreen; // Same source as getTerminalState: persist the rendered emulator model for // normal buffers so restarts replay a duplicate-free snapshot, not the raw @@ -1539,6 +1570,8 @@ export class TerminalPanelManager extends EventEmitter { if (!terminal) return null; const restore = await terminal.screenEmulator?.restoreSnapshot(); + if (this.terminals.get(panelId) !== terminal) return null; + const isAlternateScreen = restore?.isAlternateScreen ?? terminal.isAlternateScreen; // Normal-buffer restore content comes from the rendered emulator model, not // the raw append log: the log accumulates repaint traffic (forced activation @@ -1618,18 +1651,21 @@ export class TerminalPanelManager extends EventEmitter { private deriveActivityStatus(panelId: string): 'active' | 'idle' { const state = this.agentStatusMonitor.getState(panelId); - return state === 'working' || state === 'blocked' ? 'active' : 'idle'; + // Legacy idle waits must not succeed while a tracked terminal is still booting. + return state === 'working' || state === 'blocked' || + (state === undefined && this.agentStatusMonitor.isTracked(panelId)) || + this.terminals.get(panelId)?.pendingInitialCommand ? 'active' : 'idle'; } getAgentStatus(panelId: string): AgentState | undefined { return this.agentStatusMonitor.getState(panelId); } - private emitActivityStatus(terminal: TerminalProcess): void { + private emitActivityStatus(terminal: TerminalProcess, state: AgentState): void { this.sendRendererEvent('panel:activityStatus', { panelId: terminal.panelId, sessionId: terminal.sessionId, - status: this.deriveActivityStatus(terminal.panelId), + status: state === 'working' || state === 'blocked' ? 'active' : 'idle', lastActivityAt: terminal.lastActivity.toISOString() }); } @@ -1650,6 +1686,7 @@ export class TerminalPanelManager extends EventEmitter { */ private registerAgentStatusPanel(terminal: TerminalProcess): void { this.agentStatusMonitor.register(terminal.panelId, Date.now()); + this.emitAgentStatus(terminal, 'unknown', 'terminal_start'); this.ensureAgentStatusPoll(); } @@ -1662,7 +1699,7 @@ export class TerminalPanelManager extends EventEmitter { }; this.sendRendererEvent('panel:agentStatus', payload); this.emit('agent-status', payload); - this.emitActivityStatus(terminal); + this.emitActivityStatus(terminal, state); } private ensureAgentStatusPoll(): void { @@ -1687,7 +1724,7 @@ export class TerminalPanelManager extends EventEmitter { private pollAgentStatus(): void { try { for (const terminal of this.terminals.values()) { - if (!this.agentStatusMonitor.isTracked(terminal.panelId)) continue; + if (terminal.destroying || terminal.pendingInitialCommand || !this.agentStatusMonitor.isTracked(terminal.panelId)) continue; const manifest = getManifestForAgent(terminal.agentType); const emulator = terminal.screenEmulator; if (!emulator) continue; @@ -1706,56 +1743,99 @@ export class TerminalPanelManager extends EventEmitter { terminal.lastStatusScan = { screen, detection }; } const next = this.agentStatusMonitor.update(terminal.panelId, detection, Date.now()); - if (next) this.emitAgentStatus(terminal, next, detection.matchedRuleId); + if (next) { + const reason = next === detection.state ? detection.matchedRuleId : 'pty_activity'; + this.emitAgentStatus(terminal, next, reason ?? 'idle_settle'); + } } } catch (error) { console.error('[TerminalPanelManager] agent status poll failed:', error); } } - destroyTerminal(panelId: string): void { + destroyTerminal(panelId: string, options: { saveState?: boolean } = {}): Promise { const terminal = this.terminals.get(panelId); - if (!terminal) { - return; - } + if (!terminal) return Promise.resolve(); + terminal.destroying ??= this.finishDestroyTerminal(terminal, options.saveState !== false); + return terminal.destroying; + } - // Save state before destroying. `saveTerminalState` is async, so a - // surrounding synchronous `try` could never observe its rejection — and - // `panelManager.updatePanel` writes to SQLite, which can reject. - this.saveTerminalState(panelId).catch((error) => { + private async finishDestroyTerminal(terminal: TerminalProcess, saveState: boolean): Promise { + const panelId = terminal.panelId; + // Stop detection as soon as teardown begins, so output during the save + // cannot announce completion. Keep the emulator alive through any snapshot save. + this.agentStatusMonitor.unregister(panelId); + this.maybeStopAgentStatusPoll(); + try { + if (saveState) await this.saveTerminalState(panelId); + } catch (error) { console.error(`[TerminalPanelManager] Failed to save state for ${panelId}:`, error); - }); + } + if (this.terminals.get(panelId) !== terminal) return; + + try { + this.retireTerminal(terminal, terminal.exitDuringDestroy); + } finally { + // Event subscribers can throw; cleanup must still reclaim this lifetime. + try { + terminal.screenEmulator?.dispose(); + } catch (error) { + console.warn(`[TerminalPanelManager] Emulator dispose failed for ${panelId}:`, error); + } + if (!terminal.exitDuringDestroy) { + try { + if (terminal.isWSL) { + try { + terminal.pty.write('exit\r'); + } finally { + // Reclaim the PTY even if the graceful exit write failed. + setTimeout(() => { + try { terminal.pty.kill(); } catch { /* already exited */ } + }, 500); + } + } else { + terminal.pty.kill(); + } + } catch (error) { + console.error(`[TerminalPanelManager] Error killing terminal ${panelId}:`, error); + } + } + } + } - // Clear timers + /** Retire exactly one terminal lifetime, before kill can call back synchronously. */ + private retireTerminal(terminal: TerminalProcess, exit?: { exitCode: number; signal?: number }): void { + const panelId = terminal.panelId; + if (this.terminals.get(panelId) !== terminal) return; if (terminal.outputFlushTimer) { clearTimeout(terminal.outputFlushTimer); terminal.outputFlushTimer = null; } - disposeFlowControlRecord(terminal.flowControl); - this.flushOutputBuffer(terminal); - terminal.screenEmulator?.dispose(); - - // Kill the PTY process try { - if (terminal.isWSL) { - terminal.pty.write('exit\r'); - // Give WSL a moment to gracefully exit - setTimeout(() => { - try { terminal.pty.kill(); } catch { /* already exited */ } - }, 500); - } else { - terminal.pty.kill(); - } - } catch (error) { - console.error(`[TerminalPanelManager] Error killing terminal ${panelId}:`, error); + this.flushOutputBuffer(terminal); + } finally { + disposeFlowControlRecord(terminal.flowControl); + this.agentStatusMonitor.unregister(panelId); + this.terminals.delete(panelId); + this.visibleViewersByPanel.delete(panelId); + this.serializedBuffers.delete(panelId); + this.maybeStopAgentStatusPoll(); } + this.emitAgentStatus(terminal, 'idle', exit ? 'exit' : 'destroyed'); - // Remove from maps - this.terminals.delete(panelId); - this.visibleViewersByPanel.delete(panelId); - this.serializedBuffers.delete(panelId); - this.agentStatusMonitor.unregister(panelId); - this.maybeStopAgentStatusPoll(); + const data = { ...exit, timestamp: new Date().toISOString() }; + if (panelManager.getPanel(panelId)) { + void panelManager.emitPanelEvent(panelId, 'terminal:exit', data); + } else { + // The panel may already be deleted. Its terminal still owns enough + // identity to notify the journal and transport consumers of the exit. + this.sendRendererEvent('panel:event', { + type: 'terminal:exit', + source: { panelId, sessionId: terminal.sessionId, panelType: 'terminal' }, + data, + timestamp: data.timestamp, + }); + } } /** diff --git a/main/src/services/terminalStateEmulator.ts b/main/src/services/terminalStateEmulator.ts index 0fbd7d0b1..f858494ef 100644 --- a/main/src/services/terminalStateEmulator.ts +++ b/main/src/services/terminalStateEmulator.ts @@ -212,9 +212,8 @@ export class TerminalStateEmulator { dispose(): void { if (this.disposed) return; this.finalIsAlternateScreen = this.isAlternateScreen; - // Capture WITH scrollback: destroyTerminal fires saveTerminalState without - // awaiting it, so the save usually reads this snapshot after disposal — a - // viewport-only capture would silently drop the session's history. + // Preserve scrollback for reads that resume after natural exit or shutdown. + // Explicit destruction drains and saves the model before disposal. this.finalSerializedBuffer = this.serializeForRestore(true); restoreCache.delete(this); this.finalScreenText = this.getScreenText(); diff --git a/main/src/services/workspaceJournal.test.ts b/main/src/services/workspaceJournal.test.ts index bc353f6a8..8c3eef850 100644 --- a/main/src/services/workspaceJournal.test.ts +++ b/main/src/services/workspaceJournal.test.ts @@ -156,6 +156,16 @@ describe('WorkspaceJournal', () => { ]); }); + it('records exits for successive lifetimes even when the new terminal exits before its first poll', () => { + const journal = new WorkspaceJournal({ resolvePane: paneId => ({ paneId, paneName: 'Pane' }) }); + const exit = { type: 'terminal:exit', source: { panelId: 'p', sessionId: 's' }, data: { exitCode: 0 } }; + journal.send('panel:event', exit); + journal.send('panel:event', exit); + journal.send('panel:agentStatus', { panelId: 'p', sessionId: 's', state: 'unknown', reason: 'terminal_start' }); + journal.send('panel:event', exit); + expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['panel.exited', 'panel.exited']); + }); + it('ignores agent-status events from ordinary shell panels', () => { let isCliPanel = false; const journal = new WorkspaceJournal({ diff --git a/main/src/services/workspaceJournal.ts b/main/src/services/workspaceJournal.ts index 6acdc3839..983d18f06 100644 --- a/main/src/services/workspaceJournal.ts +++ b/main/src/services/workspaceJournal.ts @@ -258,6 +258,20 @@ export class WorkspaceJournal implements PaneEventSink { reason?: string | null; }): void { const { panelId, sessionId: paneId, state } = payload; + if (payload.reason === 'terminal_start') { + this.exitedPanels.delete(panelId); + this.stateByPanel.delete(panelId); + this.readySinceByPanel.delete(panelId); + return; + } + // Ending the process is not a completed agent turn. terminal:exit records + // the lifecycle event separately, including user destruction and archive. + if (payload.reason === 'exit' || payload.reason === 'destroyed') { + this.stateByPanel.delete(panelId); + this.readySinceByPanel.delete(panelId); + return; + } + this.exitedPanels.delete(panelId); const panel = this.resolvePanel?.(panelId); if (!panel?.isCliPanel) return; @@ -285,7 +299,7 @@ export class WorkspaceJournal implements PaneEventSink { agentType: panel?.agentType, from: previous, to: state, - source: payload.reason === 'exit' ? 'exit' : 'agent', + source: 'agent', reason: payload.reason ?? null, settledMs, }; diff --git a/screenshots/fresh-pane-launcher.png b/screenshots/fresh-pane-launcher.png new file mode 100644 index 000000000..c6a776070 Binary files /dev/null and b/screenshots/fresh-pane-launcher.png differ diff --git a/shared/remoteInputQueue.ts b/shared/remoteInputQueue.ts new file mode 100644 index 000000000..5bcaf1087 --- /dev/null +++ b/shared/remoteInputQueue.ts @@ -0,0 +1,110 @@ +import { boundary, decodeBoundary } from './validation/boundaryDecoder'; + +interface InputBatch { + channel: string; + data: string; + waiters: Array<{ resolve: (result: Result) => void; reject: (error: Error) => void }>; +} + +interface PanelInputQueue { + pending: InputBatch[]; + active?: { + batch: InputBatch; + controller: AbortController; + timeout: ReturnType; + }; +} + +// Bound merged requests without splitting a paste or terminal escape sequence. +const MAX_BATCH_CHARACTERS = 64 * 1024; +const INPUT_TIMEOUT_MS = 10_000; +// A bare ESC must end its write. Terminal apps that parse by read boundary, such +// as Codex and Claude Code, treat ESC followed by a key in one read as Alt+key. +const ESCAPE = '\x1b'; +const inputSchema = boundary.object({ panelId: boundary.nonEmptyString, data: boundary.string }); + +/** Serializes remote input per terminal, batching only while a request is in flight. */ +export class RemoteInputQueue { + private readonly panels = new Map>(); + + constructor(private readonly send: ( + channel: string, + args: unknown[], + signal?: AbortSignal, + ) => Promise) {} + + invoke(channel: string, args: unknown[]): Promise { + if (channel !== 'terminal:input' && channel !== 'panels:send-terminal-input') { + return this.send(channel, args); + } + const { panelId, data } = decodeBoundary({ panelId: args[0], data: args[1] }, inputSchema); + + let queue = this.panels.get(panelId); + if (!queue) { + queue = { pending: [] }; + this.panels.set(panelId, queue); + } + const result = new Promise((resolve, reject) => { + const last = queue.pending[queue.pending.length - 1]; + if ( + last + && last.channel === channel + && !last.data.endsWith(ESCAPE) + && last.data.length + data.length <= MAX_BATCH_CHARACTERS + ) { + last.data += data; + last.waiters.push({ resolve, reject }); + } else { + queue.pending.push({ channel, data, waiters: [{ resolve, reject }] }); + } + }); + if (!queue.active) void this.drain(panelId, queue); + return result; + } + + cancel(error: Error): void { + for (const [panelId, queue] of this.panels) { + this.fail(panelId, queue, error); + } + } + + private async drain(panelId: string, queue: PanelInputQueue): Promise { + const batch = queue.pending.shift(); + if (!batch) { + this.panels.delete(panelId); + return; + } + const controller = new AbortController(); + const timeout = setTimeout(() => { + this.fail(panelId, queue, new Error('Remote terminal input timed out; pending input was discarded')); + }, INPUT_TIMEOUT_MS); + queue.active = { batch, controller, timeout }; + + try { + const result = await this.send(batch.channel, [panelId, batch.data], controller.signal); + // A disconnect may have canceled this queue and created a new one for the same panel. + if (this.panels.get(panelId) !== queue) return; + clearTimeout(timeout); + queue.active = undefined; + for (const waiter of batch.waiters) waiter.resolve(result); + void this.drain(panelId, queue); + } catch (error) { + this.fail(panelId, queue, error instanceof Error ? error : new Error(String(error))); + } + } + + private fail(panelId: string, queue: PanelInputQueue, error: Error): void { + if (this.panels.get(panelId) !== queue) return; + this.panels.delete(panelId); + const batches = queue.pending; + if (queue.active) { + clearTimeout(queue.active.timeout); + queue.active.controller.abort(); + batches.unshift(queue.active.batch); + } + // Delivery of an in-flight request is uncertain. Never replay it or send its queued suffix. + for (const batch of batches) { + for (const waiter of batch.waiters) waiter.reject(error); + } + } +} diff --git a/shared/types/agentStatus.ts b/shared/types/agentStatus.ts index c578a7baa..d33cb043d 100644 --- a/shared/types/agentStatus.ts +++ b/shared/types/agentStatus.ts @@ -31,7 +31,7 @@ export interface AgentDetectionResult { visibleBlocker: boolean; /** The matched screen visibly shows live working chrome. */ visibleWorking: boolean; - /** The matched screen visibly shows live idle chrome. */ + /** The matched chrome reliably indicates idle; excludes composers also visible during work. */ visibleIdle: boolean; /** * The matched screen is an agent-owned viewer (transcript/history) rather than diff --git a/tests/adaptive-panel-layout.spec.ts b/tests/adaptive-panel-layout.spec.ts index 4810e5c31..67d7f8753 100644 --- a/tests/adaptive-panel-layout.spec.ts +++ b/tests/adaptive-panel-layout.spec.ts @@ -97,7 +97,7 @@ async function setImmersiveMode(page: Page, immersive: boolean): Promise { } test('all worktree surfaces use container bounds, accessible resizing, and durable intent', async ({ page }, testInfo) => { - await installFixture(page); + await installFixture(page, { 'pane-terminal-collapsed': 'true' }); await openWorktree(page); const inspector = page.locator('.pane-detail-panel-vertical'); @@ -779,7 +779,7 @@ test('a terminal arriving after the default branch renders switches the branch a }); test('an observed active terminal resize debounces through the existing xterm refit path', async ({ page }) => { - await installFixture(page); + await installFixture(page, { 'pane-terminal-collapsed': 'true' }); await openWorktree(page); await page.evaluate(() => { diff --git a/tests/agent-status.spec.ts b/tests/agent-status.spec.ts new file mode 100644 index 000000000..262f7803f --- /dev/null +++ b/tests/agent-status.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; +import { installElectronApiMock } from './electronApiMock'; +import type { AgentState, PanelAgentStatusEvent } from '../shared/types/agentStatus'; + +declare global { + interface Window { + __statusFixture: { + setSnapshot(state: AgentState): void; + emit(state: AgentState, reason?: string): void; + remove(): void; + reconnect(): void; + notifications: string[]; + }; + } +} + +const now = new Date(0).toISOString(); +const project = { id: 841, name: 'Status fixture', path: '/tmp/status-fixture', active: true, created_at: now, updated_at: now }; +const session = (id: string) => ({ + id, name: id, projectId: project.id, worktreePath: `${project.path}/${id}`, + prompt: '', status: 'stopped', createdAt: now, lastActivity: now, output: [], jsonMessages: [], + permissionMode: 'ignore', toolType: 'none', archived: false, isRunning: false, +}); +const panel = { + id: 'agent', sessionId: 'Agent pane', type: 'terminal', title: 'Codex', + state: { isActive: true, customState: { isCliPanel: true, agentType: 'codex', isInitialized: true, isCliReady: true } }, + metadata: { createdAt: now, lastActiveAt: now, position: 0 }, +}; + +test('sidebar and tabs reconcile status without announcing snapshot completions', async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [session('Agent pane'), session('Other pane')], initialPanels: [panel], + initialUiState: { expandedProjects: [project.id] }, initialConfig: { notifications: { enabled: true, playSound: false } }, + }); + await page.addInitScript(() => { + const api = window.electronAPI; + const originalInvoke = api.invoke; + let snapshotState: AgentState = 'working'; + const statuses = new Set<(data: PanelAgentStatusEvent) => void>(); + const deletions = new Set<(data: { panelId: string; sessionId: string }) => void>(); + const reconnects = new Set<() => void>(); + api.invoke = (channel: string, ...args: unknown[]) => { + if (channel === 'panels:agent-statuses') return Promise.resolve({ success: true, data: [ + { sessionId: 'Agent pane', panelId: 'agent', state: snapshotState }, + ] }); + return originalInvoke(channel, ...args); + }; + const originalEvents = api.events; + api.events = new Proxy(originalEvents, { get(target, key: keyof typeof originalEvents) { + if (key === 'onPanelAgentStatus') return (callback: (data: PanelAgentStatusEvent) => void) => { + statuses.add(callback); return () => statuses.delete(callback); + }; + if (key === 'onPanelDeleted') return (callback: (data: { panelId: string; sessionId: string }) => void) => { + deletions.add(callback); return () => deletions.delete(callback); + }; + if (key === 'onRemoteDaemonResyncRequested') return (callback: () => void) => { + reconnects.add(callback); return () => reconnects.delete(callback); + }; + return target[key]; + } }); + const notifications: string[] = []; + Object.defineProperty(window, 'Notification', { configurable: true, value: class { + static permission = 'granted'; + constructor(title: string) { notifications.push(title); } + } }); + api.window.isFocused = async () => false; + window.__statusFixture = { + setSnapshot: state => { snapshotState = state; }, + emit: (state, reason = 'fixture') => { for (const listener of statuses) listener({ panelId: 'agent', sessionId: 'Agent pane', state, reason }); }, + remove: () => { for (const listener of deletions) listener({ panelId: 'agent', sessionId: 'Agent pane' }); }, + reconnect: () => { for (const listener of reconnects) listener(); }, + notifications, + }; + }); + await page.goto('/'); + const sidebarPane = page.getByRole('button', { name: 'Agent pane', exact: true }); + await expect(sidebarPane).toBeVisible(); + await expect(sidebarPane.locator('..').locator('[aria-label="Agent working"]')).toBeVisible(); + await sidebarPane.click(); + const tab = page.getByRole('tab', { name: 'Codex', exact: true }).locator('..'); + await expect(tab.locator('[aria-label="Agent working"]')).toBeVisible(); + + await page.evaluate(() => { window.__statusFixture.setSnapshot('idle'); window.__statusFixture.reconnect(); }); + await expect(tab.locator('[aria-label="Agent idle"]')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__statusFixture.notifications.length)).toBe(0); + + await page.getByRole('button', { name: 'Other pane', exact: true }).click(); + for (const reason of ['exit', 'destroyed']) { + await page.evaluate(reason => { window.__statusFixture.emit('working'); window.__statusFixture.emit('idle', reason); }, reason); + await expect(sidebarPane.locator('..').locator('[aria-label="Agent idle"]')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__statusFixture.notifications)).toEqual([]); + } + await page.evaluate(() => { window.__statusFixture.emit('working'); window.__statusFixture.emit('idle'); }); + await expect(sidebarPane.locator('..').locator('[aria-label="Agent done"]')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__statusFixture.notifications)).toEqual(['Codex finished']); + await page.evaluate(() => { window.__statusFixture.emit('working'); window.__statusFixture.remove(); }); + await expect(sidebarPane.locator('..').locator('[aria-label="Agent working"]')).toHaveCount(0); +}); diff --git a/tests/electronApiMock.ts b/tests/electronApiMock.ts index 2a6d7c25a..85dcdc8ed 100644 --- a/tests/electronApiMock.ts +++ b/tests/electronApiMock.ts @@ -231,6 +231,16 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc let mockProjects = clone(mockOptions.initialProjects ?? []); let mockSessions = clone(mockOptions.initialSessions ?? []); let mockPanels = clone(mockOptions.initialPanels ?? []); + const mockLayouts = new Map(); + const setActiveMockPanel = (sessionId: string, panelId: string | null) => { + for (const panel of mockPanels) { + // SAFETY: Panel fixtures and createPanel below supply ToolPanel-shaped state objects. + const state = panel.state as JsonObject | undefined; + if (panel.sessionId === sessionId && state) { + state.isActive = panel.id === panelId; + } + } + }; const uiState = { expandedProjects: [] satisfies number[], expandedFolders: [] satisfies string[], @@ -372,7 +382,11 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc const key = args[0] === undefined ? undefined : String(args[0]); const value = args[1] === undefined ? undefined : String(args[1]); if (channel === 'panels:get-layout') { - return success(clone(mockOptions.initialLayout ?? null)); + return success(clone(key && mockLayouts.has(key) ? mockLayouts.get(key) : mockOptions.initialLayout ?? null)); + } + if (channel === 'panels:set-layout') { + if (key) mockLayouts.set(key, clone(args[1] ?? null)); + return success(); } if (channel === 'panels:shouldAutoCreate') { // Fixtures seed their own panels; the app must not grow a terminal. @@ -660,6 +674,25 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc getSessionPanels: (sessionId: string) => success( clone(mockPanels.filter((panel) => panel.sessionId === sessionId)), ), + deletePanel: (panelId: string) => { + const deleted = mockPanels.find(panel => panel.id === panelId); + mockPanels = mockPanels.filter(panel => panel.id !== panelId); + if (deleted) { + // SAFETY: The deleted record comes from the same ToolPanel-shaped mock collection. + const state = deleted.state as JsonObject | undefined; + if (state?.isActive) { + const remaining = mockPanels.filter(panel => panel.sessionId === deleted.sessionId); + const next = remaining.find(panel => panel.type !== 'explorer' && panel.type !== 'diff') ?? remaining[0]; + setActiveMockPanel(String(deleted.sessionId), next ? String(next.id) : null); + } + emit('panel:deleted', { panelId, sessionId: deleted.sessionId }); + } + return success(); + }, + setActivePanel: (sessionId: string, panelId: string) => { + setActiveMockPanel(sessionId, panelId); + return success(); + }, createPanel: (sessionId: string, type: string, title: string, initialState?: JsonObject) => { const now = new Date().toISOString(); const panel = { @@ -671,6 +704,8 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc metadata: { createdAt: now, lastActiveAt: now, position: mockPanels.length }, }; mockPanels.push(panel); + setActiveMockPanel(sessionId, panel.id); + emit('panel:created', clone(panel)); return success(clone(panel)); }, shouldAutoCreate: () => success(false), diff --git a/tests/settings.spec.ts b/tests/settings.spec.ts index 822cf98cd..ce9e9c414 100644 --- a/tests/settings.spec.ts +++ b/tests/settings.spec.ts @@ -78,6 +78,7 @@ test.describe('Settings', () => { }); test('terminal font updates the live xterm only', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('pane-terminal-collapsed', 'true')); const now = new Date(0).toISOString(); const project = { id: 913, diff --git a/tests/terminal-blur-recovery.spec.ts b/tests/terminal-blur-recovery.spec.ts index 50388313e..9b3d12d6a 100644 --- a/tests/terminal-blur-recovery.spec.ts +++ b/tests/terminal-blur-recovery.spec.ts @@ -94,6 +94,7 @@ interface BootFixtureResult { declare global { interface Window { __maskAppearances?: MaskAppearance[]; + __resumeTerminalRequest?: () => void; } } @@ -343,6 +344,59 @@ test('session remount retains full recovery', async ({ page }) => { expect(await maskAppearances(page, panel)).not.toEqual([]); }); +for (const pendingRequest of ['refocus-resize', 'forced-resize'] as const) { + test(`switching sessions during ${pendingRequest} does not produce a disposed-terminal error`, async ({ page }) => { + const { panel } = await bootFixture(page, 'performance', pendingRequest === 'forced-resize'); + const failures: string[] = []; + page.on('dialog', async (dialog) => { + failures.push(dialog.message()); + await dialog.dismiss(); + }); + page.on('pageerror', (error) => failures.push(error.message)); + page.on('console', (message) => { + if (message.text().includes('Failed to refresh terminal')) failures.push(message.text()); + }); + + // Hold one resize reply until switching sessions has disposed its caller's xterm. + await page.evaluate(({ panelId, pendingRequest }) => { + const invoke = window.electronAPI.invoke; + let held = false; + window.electronAPI.invoke = async (channel, ...args) => { + const matchesRequest = channel === 'terminal:resize' && (pendingRequest !== 'forced-resize' + || (args[3] instanceof Object && 'force' in args[3] && args[3].force === true)); + if (!held && args[0] === panelId && matchesRequest) { + held = true; + await new Promise((resolve) => { window.__resumeTerminalRequest = resolve; }); + } + return invoke(channel, ...args); + }; + }, { panelId: primaryPanel.id, pendingRequest }); + + if (pendingRequest === 'refocus-resize') { + await emitFocus(page, false); + await waitForWindowFocusState(page, false); + await emitFocus(page, true); + } else { + await panel.hover(); + await panel.getByTitle('Refresh terminal').click(); + } + await expect.poll(() => page.evaluate(() => window.__resumeTerminalRequest !== undefined)).toBe(true); + + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await expect(panel).toHaveCount(0); + const other = page.getByRole('tabpanel', { name: otherPanel.title }); + await expect(other.locator('.xterm-screen')).toBeVisible(); + await expect(other.getByTestId('terminal-activation-mask')).toHaveCount(0); + const before = await readSnapshot(other); + + await page.evaluate(() => { window.__resumeTerminalRequest?.(); }); + await advanceActivation(page); + + expect(failures).toEqual([]); + expect(await readSnapshot(other)).toEqual(before); + }); +} + test('WebGL context loss keeps content readable without an activation mask', async ({ page }) => { const { panel, webglLoaded } = await bootFixture(page); test.skip(!webglLoaded, 'WebGL renderer not attached; context-loss path is not reachable in this browser'); diff --git a/tests/terminal-dock.spec.ts b/tests/terminal-dock.spec.ts new file mode 100644 index 000000000..9adf92b82 --- /dev/null +++ b/tests/terminal-dock.spec.ts @@ -0,0 +1,272 @@ +import { expect, test, type Page } from '@playwright/test'; +import { installElectronApiMock } from './electronApiMock'; + +const now = new Date(0).toISOString(); +const project = { id: 814, name: 'Dock fixture', path: '/tmp/dock-fixture', active: true, created_at: now, updated_at: now }; +const mainSession = { + id: 'dock-main', name: 'Dock fixture (Main)', projectId: project.id, + worktreePath: project.path, isMainRepo: true, prompt: '', status: 'stopped', + createdAt: now, lastActivity: now, output: [], jsonMessages: [], isRunning: false, + permissionMode: 'ignore', toolType: 'none', archived: false, displayOrder: 0, + gitStatus: { state: 'clean', ahead: 0, behind: 0, hasUncommittedChanges: false, hasUntrackedFiles: false, filesChanged: 0 }, +}; +const otherSession = { ...mainSession, id: 'dock-other', name: 'Other pane', isMainRepo: false, displayOrder: 1 }; +const shell = { + id: 'default-shell', sessionId: mainSession.id, type: 'terminal', title: 'Terminal', + state: { isActive: true, customState: {} }, + metadata: { createdAt: now, lastActiveAt: now, position: 0 }, +}; + +async function openMainProject(page: Page) { + await page.getByRole('button', { name: `Repository actions for ${project.name}`, exact: true }).click(); + await page.getByText('Open session on main', { exact: true }).click(); + await expect(page.locator('.pane-project-content')).toBeVisible(); + await expect(page.getByRole('status', { name: 'Loading main repository session' })).toHaveCount(0); +} + +async function expandRepository(page: Page) { + await expect(page.getByRole('button', { name: `Repository actions for ${project.name}`, exact: true })).toBeVisible(); + const expand = page.getByRole('button', { name: `Expand repository ${project.name}`, exact: true }); + if (await expand.isVisible()) await expand.click(); +} + +for (const withDock of [false, true]) { + for (const agent of ['Claude Code', 'Codex']) { + test(`an empty main repository ${withDock ? 'with' : 'without'} a dock offers and launches ${agent}`, async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession], + initialPanels: withDock ? [shell] : [], activeProjectId: project.id, + }); + await page.goto('/'); + await openMainProject(page); + const stage = page.locator('.pane-project-content .pane-center-column > div').first(); + for (const label of ['Terminal', 'Claude Code', 'Codex']) { + await expect(stage.getByRole('button', { name: new RegExp(`^${label}`) })).toBeVisible(); + } + await stage.getByRole('button', { name: new RegExp(`^${agent}`) }).click(); + await expect(page.getByRole('tab', { name: agent, exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(stage.getByRole('button', { name: /^Codex/ })).toHaveCount(0); + const result = await page.evaluate(async id => window.electronAPI.panels.getSessionPanels(id), mainSession.id); + expect(result.data?.find(panel => panel.title === agent)?.state.customState).toMatchObject({ + initialCommand: agent === 'Codex' ? 'codex --yolo' : 'claude --dangerously-skip-permissions', + }); + await page.getByRole('button', { name: `Close ${agent}`, exact: true }).click(); + await expect(stage.getByRole('button', { name: /^Codex/ })).toBeVisible(); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(withDock ? 1 : 0); + }); + } +} + +test('the main repository shell starts in an expanded bottom dock with no top tab', async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession, otherSession], + initialPanels: [shell], activeProjectId: project.id, + }); + await page.goto('/'); + await openMainProject(page); + await expect(page.locator('.pane-terminal-dock .pane-terminal-shell-body')).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Terminal', exact: true })).toHaveCount(0); + await expect(page.locator('.panel-tab-bar [role="tab"]')).toHaveCount(0); + + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(page.locator('.pane-terminal-dock .pane-terminal-shell-body')).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Terminal', exact: true })).toHaveCount(0); +}); + +for (const agent of ['Codex', 'Claude Code']) { + test(`${agent} stays a top tab after returning to a main pane whose shell was removed`, async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession, otherSession], + initialPanels: [shell], activeProjectId: project.id, + // Old persisted layouts omitted the agent when it was mistaken for the dock. + initialLayout: { + version: 1, focusedGroupId: 'empty', + root: { type: 'group', id: 'empty', panelIds: [], activePanelId: null }, + }, + }); + await page.addInitScript(() => localStorage.setItem('pane-terminal-collapsed', 'true')); + await page.goto('/'); + await openMainProject(page); + await page.getByRole('button', { name: 'Close terminal', exact: true }).click(); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(0); + await page.getByRole('button', { name: 'Hide details', exact: true }).click(); + await page.getByRole('button', { name: 'Add tool', exact: true }).click(); + await page.getByRole('menuitem', { name: new RegExp(agent) }).click(); + const tab = page.getByRole('tab', { name: agent, exact: true }); + await expect(tab).toBeVisible(); + const tabId = await tab.getAttribute('id'); + + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(tab).toBeVisible(); + await expect(tab).toHaveAttribute('id', tabId!); + await expect(tab).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(0); + + // New agents created directly in SessionView must also enter the layout. + await page.getByRole('button', { name: 'Add tool', exact: true }).click(); + await page.getByRole('menuitem', { name: /Cursor/ }).click(); + await expect(page.getByRole('tab', { name: 'Cursor', exact: true })).toBeVisible(); + + // Loading the same main session through the project entry must agree. + await openMainProject(page); + await expect(tab).toBeVisible(); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(0); + + // Reopening a shell expands the dock, and closing it leaves the active agent alone. + await page.getByRole('button', { name: 'Add tool', exact: true }).click(); + await page.getByRole('menuitem', { name: /^Terminal/ }).click(); + await expect(page.locator('.pane-terminal-dock .pane-terminal-shell-body')).toBeVisible(); + const cursorTab = page.getByRole('tab', { name: 'Cursor', exact: true }); + await cursorTab.click(); + await page.getByRole('button', { name: 'Close terminal', exact: true }).click(); + await expect(cursorTab).toHaveAttribute('aria-selected', 'true'); + await expect(tab).toBeVisible(); + }); +} + +test('a restored agent identified only by runtime metadata stays out of the dock', async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession], activeProjectId: project.id, + initialPanels: [{ ...shell, id: 'restored-agent', title: 'Codex', state: { isActive: true, customState: { agentType: 'codex', isCliPanel: true } } }], + initialLayout: { + version: 1, focusedGroupId: 'empty', + root: { type: 'group', id: 'empty', panelIds: [], activePanelId: null }, + }, + }); + await page.goto('/'); + await expandRepository(page); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(page.getByRole('tab', { name: 'Codex', exact: true })).toBeVisible(); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(0); +}); + +test('a fresh worktree profile defaults to an expanded dock and preserves an explicit collapse', async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [otherSession], activeProjectId: project.id, + initialPanels: [{ ...shell, sessionId: otherSession.id }], + }); + await page.goto('/'); + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await expect(page.locator('.pane-terminal-shell-body')).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('pane-terminal-collapsed'))).toBe('false'); + await page.getByRole('button', { name: 'Collapse terminal', exact: true }).click(); + await page.reload(); + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await expect(page.getByRole('button', { name: 'Expand terminal', exact: true })).toBeVisible(); + await expect(page.locator('.pane-terminal-shell-body')).toHaveCount(0); +}); + +for (const { deletion, split } of [ + { deletion: 'dock button', split: false }, + { deletion: 'backend event', split: false }, + { deletion: 'dock button', split: true }, + { deletion: 'backend event', split: true }, +]) { + test(`promoting a shell via ${deletion} repairs the ${split ? 'split' : 'single'} layout and persists selection`, async ({ page }) => { + const extraShell = { ...shell, id: 'extra-shell', title: 'Extra shell', state: { isActive: true, customState: {} } }; + const agent = { ...shell, id: 'codex', title: 'Codex', state: { isActive: false, customState: { initialCommand: 'codex --yolo' } } }; + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession, otherSession], activeProjectId: project.id, + initialPanels: [{ ...shell, state: { isActive: false, customState: {} } }, extraShell, agent], + initialLayout: split ? { + version: 1, focusedGroupId: 'shell-group', zoomedGroupId: 'shell-group', + root: { + type: 'split', id: 'split', direction: 'row', sizes: [1, 1], + children: [ + { type: 'group', id: 'shell-group', panelIds: [extraShell.id], activePanelId: extraShell.id }, + { type: 'group', id: 'agent-group', panelIds: [agent.id], activePanelId: agent.id }, + ], + }, + } : { + version: 1, focusedGroupId: 'agent-group', + root: { type: 'group', id: 'agent-group', panelIds: [extraShell.id, agent.id], activePanelId: extraShell.id }, + }, + }); + await page.goto('/'); + await expandRepository(page); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(page.getByRole('tab', { name: 'Extra shell', exact: true })).toBeVisible(); + + if (deletion === 'dock button') { + await page.getByRole('button', { name: 'Close terminal', exact: true }).click(); + } else { + await page.evaluate(id => window.electronAPI.panels.deletePanel(id), shell.id); + } + + const codexTab = page.getByRole('tab', { name: 'Codex', exact: true }); + await expect(codexTab).toBeVisible(); + await expect(codexTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tabpanel', { name: 'Codex', exact: true })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Extra shell', exact: true })).toHaveCount(0); + await expect(page.locator('.pane-terminal-dock')).toBeVisible(); + + await expect.poll(() => page.evaluate(async id => { + return (await window.electronAPI.invoke('panels:get-layout', id)).data; + }, mainSession.id)).toEqual({ + version: 1, focusedGroupId: 'agent-group', zoomedGroupId: null, + root: { type: 'group', id: 'agent-group', panelIds: [agent.id], activePanelId: agent.id }, + }); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(codexTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tabpanel', { name: 'Codex', exact: true })).toBeVisible(); + }); +} + +test('opening a project preserves a non-first persisted active tab', async ({ page }) => { + const agents = ['Codex', 'Claude Code', 'Cursor'].map((title, index) => ({ + ...shell, id: `agent-${index}`, title, + state: { isActive: title === 'Cursor', customState: { initialCommand: title } }, + })); + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession, otherSession], activeProjectId: project.id, + initialPanels: [{ ...shell, state: { isActive: false, customState: {} } }, ...agents], + }); + await page.goto('/'); + await openMainProject(page); + const cursorTab = page.getByRole('tab', { name: 'Cursor', exact: true }); + const stage = page.locator('.pane-project-content .pane-center-column > div').first(); + await expect(cursorTab).toHaveAttribute('aria-selected', 'true'); + await expect(stage.locator('.xterm:visible')).toHaveCount(1); + await expect.poll(() => page.evaluate(async id => { + const result = await window.electronAPI.panels.getSessionPanels(id); + return result.data?.find(panel => panel.state.isActive)?.title; + }, mainSession.id)).toBe('Cursor'); + + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await openMainProject(page); + await expect(cursorTab).toHaveAttribute('aria-selected', 'true'); + await expect(stage.locator('.xterm:visible')).toHaveCount(1); +}); + +test('project fallback selection agrees with the backend when the active panel belongs to the dock', async ({ page }) => { + const agent = { ...shell, id: 'codex', title: 'Codex', state: { isActive: false, customState: { initialCommand: 'codex --yolo' } } }; + const secondAgent = { ...agent, id: 'claude', title: 'Claude Code', state: { isActive: false, customState: { initialCommand: 'claude' } } }; + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession], activeProjectId: project.id, + initialPanels: [shell, agent, secondAgent], + }); + await page.goto('/'); + await openMainProject(page); + await expect(page.getByRole('tab', { name: 'Codex', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => page.evaluate(async id => { + const result = await window.electronAPI.panels.getSessionPanels(id); + return result.data?.find(panel => panel.state.isActive)?.id; + }, mainSession.id)).toBe(agent.id); + + // SessionView remains mounted around ProjectView and handles backend deletion events. + await page.evaluate(id => window.electronAPI.panels.deletePanel(id), agent.id); + await expect(page.getByRole('tab', { name: 'Codex', exact: true })).toHaveCount(0); + await expect(page.getByRole('tab', { name: 'Claude Code', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => page.evaluate(async id => { + const result = await window.electronAPI.panels.getSessionPanels(id); + return result.data?.find(panel => panel.state.isActive)?.id; + }, mainSession.id)).toBe(secondAgent.id); +}); diff --git a/tests/terminal-selection-popover.spec.ts b/tests/terminal-selection-popover.spec.ts index 230a9b389..b5d3129e7 100644 --- a/tests/terminal-selection-popover.spec.ts +++ b/tests/terminal-selection-popover.spec.ts @@ -120,6 +120,7 @@ async function selectFirstLine(page: Page, terminal: Locator): Promise { } test('selection popover works in restored bottom and tab terminals', async ({ page }, testInfo) => { + await page.addInitScript(() => localStorage.setItem('pane-terminal-collapsed', 'true')); await installClipboardMock(page); await installElectronApiMock(page, { initialProjects: [project], @@ -183,6 +184,10 @@ test('keeps keyboard copy available when Pane shortcuts are disabled', async ({ await page.getByRole('button', { name: session.name, exact: true }).click(); const terminal = page.getByRole('tabpanel').locator('.xterm').first(); + await expect(terminal).toBeVisible(); + // Wait after xterm mounts: initial restore clears any earlier selection. + await expect(page.getByRole('tabpanel').getByRole('status', { name: 'Loading terminal' })).toHaveCount(0); + await expect(page.getByRole('tabpanel').getByTestId('terminal-activation-mask')).toHaveCount(0); await selectFirstLine(page, terminal); await terminal.locator('.xterm-helper-textarea').focus(); await page.keyboard.press('Control+Shift+C');