From 4e0eed717dc2ed798f1946d172f7c77bac4d3a70 Mon Sep 17 00:00:00 2001 From: Matt Visnovsky Date: Sat, 5 Sep 2026 03:04:36 -0600 Subject: [PATCH] fix: Project action terminal lifecycle (#3287) * fix(terminal): make command sessions own action lifecycle * fix(ui): reconcile project action terminal state * feat(ui): show running project actions in terminal tabs * feat(ui): run project actions from linked worktrees * fix(ui): guard project action reconciliation * fix(ui): scope project action preview fallback * fix(ui): default project actions to worktrees * fix(ui): reveal project action terminals * fix(ui): retain terminal output after snapshot replay * fix(ui): restore running action terminals on revisit --- packages/ui/src/apps/MobileApp.tsx | 5 + .../ui/src/components/layout/ContextPanel.tsx | 8 +- .../ui/src/components/layout/MainLayout.tsx | 2 + .../layout/ProjectActionsButton.test.tsx | 505 ++++++++++++ .../layout/ProjectActionsButton.tsx | 777 +++++++++++++----- .../ui/src/components/layout/VSCodeLayout.tsx | 2 + .../contextPanelTerminalTarget.test.ts | 36 + .../projects/ProjectActionsSection.test.tsx | 80 ++ .../projects/ProjectActionsSection.tsx | 45 +- .../terminal/TerminalViewport.test.tsx | 255 ++++++ .../components/terminal/TerminalViewport.tsx | 31 +- .../terminal/terminalChunkReplay.test.ts | 67 ++ .../terminal/terminalChunkReplay.ts | 52 ++ .../components/views/TerminalView.test.tsx | 405 +++++++++ .../ui/src/components/views/TerminalView.tsx | 176 ++-- .../__tests__/terminalViewportRemount.test.ts | 4 +- .../hooks/useProjectActionsContext.test.ts | 76 ++ .../ui/src/hooks/useProjectActionsContext.ts | 52 +- .../useTerminalSessionKeepalive.test.tsx | 186 +++++ .../src/hooks/useTerminalSessionKeepalive.ts | 29 + packages/ui/src/lib/api/types.ts | 27 +- .../ui/src/lib/i18n/messages/de.settings.ts | 5 + .../ui/src/lib/i18n/messages/en.settings.ts | 5 + .../ui/src/lib/i18n/messages/es.settings.ts | 5 + .../ui/src/lib/i18n/messages/fr.settings.ts | 5 + .../ui/src/lib/i18n/messages/ja.settings.ts | 5 + .../ui/src/lib/i18n/messages/ko.settings.ts | 5 + .../ui/src/lib/i18n/messages/pl.settings.ts | 5 + .../src/lib/i18n/messages/pt-BR.settings.ts | 5 + .../ui/src/lib/i18n/messages/tr.settings.ts | 5 + .../ui/src/lib/i18n/messages/uk.settings.ts | 5 + .../src/lib/i18n/messages/zh-CN.settings.ts | 5 + .../src/lib/i18n/messages/zh-TW.settings.ts | 5 + packages/ui/src/lib/openchamberConfig.test.ts | 150 ++++ packages/ui/src/lib/openchamberConfig.ts | 11 +- .../ui/src/lib/projectActionTerminal.test.ts | 271 +++++- packages/ui/src/lib/projectActionTerminal.ts | 232 +++++- packages/ui/src/lib/projectResolution.test.ts | 18 + packages/ui/src/lib/projectResolution.ts | 20 +- packages/ui/src/lib/terminalApi.test.ts | 141 +++- packages/ui/src/lib/terminalApi.ts | 130 ++- packages/ui/src/stores/DOCUMENTATION.md | 4 + packages/ui/src/stores/useConfigStore.test.ts | 1 + .../ui/src/stores/useTerminalStore.test.ts | 385 ++++++++- packages/ui/src/stores/useTerminalStore.ts | 497 ++++++++--- .../stores/useUIStore.contextPanel.test.ts | 267 ++++++ packages/ui/src/stores/useUIStore.ts | 84 +- .../ui/src/sync/__tests__/issue-2039.test.ts | 1 + .../web/server/lib/terminal/DOCUMENTATION.md | 14 +- packages/web/server/lib/terminal/runtime.js | 140 +++- .../web/server/lib/terminal/runtime.test.js | 506 +++++++++++- packages/web/server/lib/terminal/shells.js | 17 + .../web/server/lib/terminal/shells.test.js | 33 +- 53 files changed, 5194 insertions(+), 608 deletions(-) create mode 100644 packages/ui/src/components/layout/ProjectActionsButton.test.tsx create mode 100644 packages/ui/src/components/layout/__tests__/contextPanelTerminalTarget.test.ts create mode 100644 packages/ui/src/components/sections/projects/ProjectActionsSection.test.tsx create mode 100644 packages/ui/src/components/terminal/TerminalViewport.test.tsx create mode 100644 packages/ui/src/components/terminal/terminalChunkReplay.test.ts create mode 100644 packages/ui/src/components/terminal/terminalChunkReplay.ts create mode 100644 packages/ui/src/components/views/TerminalView.test.tsx create mode 100644 packages/ui/src/hooks/useProjectActionsContext.test.ts create mode 100644 packages/ui/src/hooks/useTerminalSessionKeepalive.test.tsx create mode 100644 packages/ui/src/hooks/useTerminalSessionKeepalive.ts create mode 100644 packages/ui/src/lib/openchamberConfig.test.ts diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 0d1dbab9..0190da3f 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -18,6 +18,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useRouter } from '@/hooks/useRouter'; +import { useTerminalSessionKeepalive } from '@/hooks/useTerminalSessionKeepalive'; import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; @@ -105,6 +106,10 @@ type MobileSurface = 'instances' | 'settings' | 'update'; const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); + // The mobile root does not mount MainLayout, so it owns its own terminal + // keepalive: without it, background PTYs (running project actions included) + // are idle-reaped by the server while the workspace drawer is closed. + useTerminalSessionKeepalive(); const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); const [activeSurface, setActiveSurface] = React.useState(null); // Phone right drawer with the workspace tabs; the tab persists across diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 3e86e28b..2d5b1920 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -969,8 +969,8 @@ export const ContextPanel: React.FC = () => { () => tabs.filter((tab) => tab.mode === 'diff'), [tabs], ); - const hasTerminalTab = React.useMemo( - () => tabs.some((tab) => tab.mode === 'terminal'), + const terminalTab = React.useMemo( + () => tabs.find((tab) => tab.mode === 'terminal') ?? null, [tabs], ); // Keep-alive: the walkthrough holds reading progress and scroll position that @@ -1283,9 +1283,9 @@ export const ContextPanel: React.FC = () => { ))} - {hasTerminalTab ? ( + {terminalTab ? (
- +
) : null} {hasWalkthroughTab ? ( diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 847c6894..5a12c820 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -26,6 +26,7 @@ import { useSessionTreeMoveConfirmation, } from '@/lib/worktrees/sessionWorktreeMove'; import { useUpdatePolling } from '@/hooks/useUpdatePolling'; +import { useTerminalSessionKeepalive } from '@/hooks/useTerminalSessionKeepalive'; import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; @@ -43,6 +44,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se */ export const MainLayout: React.FC = () => { useSessionListSync({ isVSCode: false }); + useTerminalSessionKeepalive(); const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const setIsMobile = useUIStore((state) => state.setIsMobile); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); diff --git a/packages/ui/src/components/layout/ProjectActionsButton.test.tsx b/packages/ui/src/components/layout/ProjectActionsButton.test.tsx new file mode 100644 index 00000000..4fbcf7be --- /dev/null +++ b/packages/ui/src/components/layout/ProjectActionsButton.test.tsx @@ -0,0 +1,505 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Window } from 'happy-dom'; + +import { I18nProvider } from '@/lib/i18n'; +import type { CreateTerminalOptions, TerminalHandlers, TerminalStreamEvent } from '@/lib/api/types'; +import { useTerminalStore } from '@/stores/useTerminalStore'; +import type { OpenChamberProjectAction } from '@/lib/openchamberConfig'; + +const toastCalls = { + error: new Array(), + info: new Array(), + success: new Array(), +} satisfies { error: string[]; info: string[]; success: string[] }; + +const openContextPreviewCalls: Array<{ directory: string; url: string }> = []; +const openContextPanelTabCalls: Array<{ directory: string; mode: string; targetDirectory: string | null | undefined }> = []; +const openExternalCalls: string[] = []; +const detectedDevServer: MockedDetectedDevServer = { command: null, previewUrlHint: null }; +const mockedDeviceInfo = { isMobile: false, isTablet: false, hasTouchOnlyPointer: false }; +let effectiveDirectory = '/repo'; + +const uiState = { + terminalShell: 'zsh', + terminalLoginShells: ['zsh'], + setSettingsPage: () => undefined, + setSettingsDialogOpen: () => undefined, + setSettingsProjectsSelectedId: () => undefined, + openContextPreview: (directory: string, url: string) => { + openContextPreviewCalls.push({ directory, url }); + }, + openContextPanelTab: (directory: string, options: { mode: string; targetDirectory?: string | null }) => { + openContextPanelTabCalls.push({ directory, mode: options.mode, targetDirectory: options.targetDirectory }); + }, + openContextSurface: () => undefined, +}; + +const useUiStoreMock = Object.assign( + (selector: (state: typeof uiState) => T): T => selector(uiState), + { getState: () => uiState }, +); + +const desktopSshState = { instances: [], load: async () => undefined }; +const useDesktopSshStoreMock = (selector: (state: typeof desktopSshState) => T): T => selector(desktopSshState); + +type SubscriptionRecord = { + sessionId: string; + handlers: TerminalHandlers; + closed: number; +}; + +interface MockedActionsState { + actions: OpenChamberProjectAction[]; +} + +interface MockedDetectedDevServer { + command: string | null; + previewUrlHint: string | null; +} + +const createCalls: CreateTerminalOptions[] = []; +const sendCalls: string[] = []; +const forceKillCalls: string[] = []; +const closeCalls: string[] = []; +const subscriptions: SubscriptionRecord[] = []; +let sessionCounter = 0; +const mockedActionsState: MockedActionsState = { + actions: [{ id: 'build', name: 'Build', command: 'echo hello', icon: 'build' }], +}; + +const emitToSession = (sessionId: string, event: TerminalStreamEvent) => { + subscriptions + .filter((entry) => entry.sessionId === sessionId && entry.closed === 0) + .forEach((entry) => entry.handlers.onEvent(event)); +}; + +const terminal = { + listSessions: async () => [], + createSession: async (options: CreateTerminalOptions) => { + createCalls.push(options); + sessionCounter += 1; + return { + sessionId: `session-${sessionCounter}`, + cols: 80, + rows: 24, + status: 'running' as const, + mode: 'command' as const, + purpose: options.purpose, + }; + }, + connect: (sessionId: string, handlers: SubscriptionRecord['handlers']) => { + const record: SubscriptionRecord = { sessionId, handlers, closed: 0 }; + subscriptions.push(record); + return { + close: () => { + record.closed += 1; + }, + }; + }, + sendInput: async (sessionId: string, input: string) => { + sendCalls.push(`${sessionId}:${input}`); + queueMicrotask(() => { + emitToSession(sessionId, { type: 'exit', sequence: 1, exitCode: 0, signal: null }); + }); + }, + resize: async () => undefined, + updateAppearance: async () => undefined, + close: async (sessionId: string) => { + closeCalls.push(sessionId); + }, + restartSession: async () => { throw new Error('not used'); }, + forceKill: async ({ sessionId }: { sessionId?: string }) => { + forceKillCalls.push(sessionId ?? ''); + }, +}; + +mock.module('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode; onClick?: () => void; className?: string }) => React.createElement('button', { type: 'button', onClick, className }, children), + DropdownMenuSeparator: () => React.createElement('hr'), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), +})); +mock.module('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), +})); +mock.module('@/components/ui', () => ({ + toast: { + error: (message: string) => { toastCalls.error.push(message); }, + info: (message: string) => { toastCalls.info.push(message); }, + success: (message: string) => { toastCalls.success.push(message); }, + }, +})); +mock.module('@/components/icon/Icon', () => ({ Icon: ({ name, className }: { name: string; className?: string }) => React.createElement('span', { 'data-icon': name, className }) })); +mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ terminal, runtime: { isVSCode: false, platform: 'web' } }) })); +mock.module('@/lib/device', () => ({ useDeviceInfo: () => mockedDeviceInfo })); +mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false })); +mock.module('@/stores/useUIStore', () => ({ useUIStore: useUiStoreMock })); +mock.module('@/contexts/useThemeSystem', () => ({ useThemeSystem: () => ({ currentTheme: { metadata: { variant: 'dark' }, colors: { surface: { background: '#000' }, syntax: { base: { foreground: '#fff' } } } } }) })); +mock.module('@/stores/useDesktopSshStore', () => ({ useDesktopSshStore: useDesktopSshStoreMock })); +mock.module('@/lib/url', () => ({ openExternalUrl: async (url: string) => { openExternalCalls.push(url); } })); +mock.module('@/lib/openchamberConfig', () => ({ + getProjectActionsState: async () => mockedActionsState, +})); +mock.module('@/lib/browser/announcedServers', () => ({ setAnnouncedDevServers: () => undefined })); +mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => effectiveDirectory })); +mock.module('@/lib/detectDevServer', () => ({ + detectDevServerCommand: async () => ( + detectedDevServer.command + ? { command: detectedDevServer.command, previewUrlHint: detectedDevServer.previewUrlHint ?? undefined } + : null + ), + readPackageJsonScripts: async () => ({}), +})); + +const { ProjectActionsButton } = await import('./ProjectActionsButton'); + +describe('ProjectActionsButton lifecycle', () => { + let windowInstance: Window; + let root: Root; + let host: HTMLDivElement; + const scheduledWindowTimeouts = new Map, { delay: number; run: () => void }>(); + + const runWindowTimeouts = async (delay: number) => { + const matching = [...scheduledWindowTimeouts.entries()].filter(([, timeout]) => timeout.delay === delay); + for (const [id] of matching) scheduledWindowTimeouts.delete(id); + await act(async () => { + for (const [, timeout] of matching) timeout.run(); + await Promise.resolve(); + }); + }; + + beforeEach(() => { + windowInstance = new Window({ url: 'http://localhost/' }); + scheduledWindowTimeouts.clear(); + const originalSetTimeout = windowInstance.setTimeout.bind(windowInstance); + const originalClearTimeout = windowInstance.clearTimeout.bind(windowInstance); + windowInstance.setTimeout = (callback, delay = 0, ...args) => { + const id = originalSetTimeout(() => undefined, 0); + originalClearTimeout(id); + scheduledWindowTimeouts.set(id, { delay, run: () => callback(...args) }); + return id; + }; + windowInstance.clearTimeout = (id) => { + if (id !== undefined) scheduledWindowTimeouts.delete(id); + }; + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + navigator: windowInstance.navigator, + Node: windowInstance.Node, + Element: windowInstance.Element, + HTMLElement: windowInstance.HTMLElement, + Event: windowInstance.Event, + MouseEvent: windowInstance.MouseEvent, + IS_REACT_ACT_ENVIRONMENT: true, + }); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + useTerminalStore.getState().clearAll(); + createCalls.length = 0; + sendCalls.length = 0; + forceKillCalls.length = 0; + closeCalls.length = 0; + subscriptions.length = 0; + toastCalls.error.length = 0; + toastCalls.info.length = 0; + toastCalls.success.length = 0; + openContextPreviewCalls.length = 0; + openContextPanelTabCalls.length = 0; + openExternalCalls.length = 0; + detectedDevServer.command = null; + detectedDevServer.previewUrlHint = null; + mockedDeviceInfo.isMobile = true; + effectiveDirectory = '/repo'; + sessionCounter = 0; + mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', icon: 'build' }]; + }); + + afterEach(async () => { + await act(async () => root.unmount()); + }); + + const renderButton = async ({ + projectPath = '/repo', + directory = '/repo', + }: { projectPath?: string; directory?: string } = {}) => { + await act(async () => { + root.render( + React.createElement(I18nProvider, null, + React.createElement(ProjectActionsButton, { + projectRef: { id: 'project-1', path: projectPath }, + directory, + allowMobile: true, + }), + ), + ); + }); + await act(async () => { await Promise.resolve(); }); + }; + + test('runs, stops, and reruns on the same action tab while cleaning old subscriptions once', async () => { + await renderButton(); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + const firstTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action'); + expect(firstTab?.terminalSessionId).toBe('session-1'); + expect(firstTab?.purpose.type).toBe('project-action'); + const firstExecution = firstTab?.purpose.type === 'project-action' ? firstTab.purpose.executionId : null; + expect(firstExecution).not.toBeNull(); + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + const stoppedTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action'); + expect(stoppedTab?.lifecycle).toBe('exited'); + expect(stoppedTab?.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null }); + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + const rerunTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action'); + expect(rerunTab?.id).toBe(firstTab?.id); + expect(rerunTab?.terminalSessionId).toBe('session-2'); + expect(rerunTab?.lifecycle).toBe('running'); + const secondExecution = rerunTab?.purpose.type === 'project-action' ? rerunTab.purpose.executionId : null; + expect(secondExecution).not.toBeNull(); + expect(secondExecution).not.toBe(firstExecution); + + expect(createCalls).toHaveLength(2); + expect(sendCalls).toEqual(['session-1:\x03']); + expect(forceKillCalls).toEqual([]); + expect(closeCalls).toEqual(['session-1']); + expect(subscriptions.map((entry) => entry.closed)).toEqual([1, 1, 0]); + }); + + test('default action runs in the current worktree and stores its tab there', async () => { + effectiveDirectory = '/repo-worktree'; + await renderButton({ projectPath: '/repo', directory: '/repo-worktree' }); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(createCalls).toHaveLength(1); + expect(createCalls[0]?.cwd).toBe('/repo-worktree'); + expect(useTerminalStore.getState().getDirectoryState('/repo-worktree')?.tabs.some((tab) => tab.purpose.type === 'project-action' && tab.purpose.actionId === 'build')).toBe(true); + expect(useTerminalStore.getState().getDirectoryState('/repo')?.tabs.some((tab) => tab.purpose.type === 'project-action') ?? false).toBe(false); + expect(openContextPanelTabCalls).toEqual([{ directory: '/repo-worktree', mode: 'terminal', targetDirectory: null }]); + }); + + test('parent action runs in the parent checkout, stores its tab there, and reveals it from the live worktree host', async () => { + mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', icon: 'build', runIn: 'parent' }]; + effectiveDirectory = '/repo-worktree'; + await renderButton({ projectPath: '/repo', directory: '/repo-worktree' }); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(createCalls).toHaveLength(1); + expect(createCalls[0]?.cwd).toBe('/repo'); + expect(useTerminalStore.getState().getDirectoryState('/repo')?.tabs.some((tab) => tab.purpose.type === 'project-action' && tab.purpose.actionId === 'build')).toBe(true); + expect(useTerminalStore.getState().getDirectoryState('/repo-worktree')?.tabs.some((tab) => tab.purpose.type === 'project-action') ?? false).toBe(false); + expect(openContextPanelTabCalls).toEqual([{ directory: '/repo-worktree', mode: 'terminal', targetDirectory: '/repo' }]); + }); + + test('project action reveal uses the live effective host instead of the sticky action context directory', async () => { + effectiveDirectory = '/live-host'; + await renderButton({ projectPath: '/repo', directory: '/repo-worktree' }); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(createCalls).toHaveLength(1); + expect(createCalls[0]?.cwd).toBe('/repo-worktree'); + expect(openContextPanelTabCalls).toEqual([{ directory: '/live-host', mode: 'terminal', targetDirectory: '/repo-worktree' }]); + }); + + test('auto-discover without a preview hint settles on an announced localhost URL in context preview only', async () => { + mockedDeviceInfo.isMobile = false; + detectedDevServer.command = 'bun run dev'; + await renderButton(); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + const autoDiscoverTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => ( + tab.purpose.type === 'project-action' && tab.purpose.actionId === '__openchamber_auto_discover_preview__' + )); + expect(autoDiscoverTab?.terminalSessionId).toBe('session-1'); + + await act(async () => { + emitToSession('session-1', { + type: 'data', + data: 'Ready at http://127.0.0.1:4321\n', + sequence: 1, + replayData: undefined, + }); + }); + await runWindowTimeouts(3_000); + + expect(openContextPreviewCalls).toEqual([{ directory: '/repo', url: 'http://127.0.0.1:4321' }]); + expect(openExternalCalls).toEqual([]); + await runWindowTimeouts(15_000); + expect(openContextPanelTabCalls).toEqual([]); + }); + + test('auto-discover opens its terminal when no preview URL appears before the fallback timeout', async () => { + mockedDeviceInfo.isMobile = false; + detectedDevServer.command = 'bun run dev'; + effectiveDirectory = '/repo-worktree'; + await renderButton({ projectPath: '/repo', directory: '/repo' }); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(openContextPanelTabCalls).toEqual([]); + effectiveDirectory = '/switched-after-launch'; + await runWindowTimeouts(15_000); + expect(openContextPanelTabCalls).toEqual([{ directory: '/repo-worktree', mode: 'terminal', targetDirectory: '/repo' }]); + }); + + test('unmount closes active subscriptions and cancels pending preview timeouts', async () => { + mockedDeviceInfo.isMobile = false; + detectedDevServer.command = 'bun run dev'; + effectiveDirectory = '/repo-worktree'; + await renderButton({ projectPath: '/repo', directory: '/repo' }); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(subscriptions.length).toBeGreaterThan(0); + expect(subscriptions.every((entry) => entry.closed === 0)).toBe(true); + + await act(async () => { + root.unmount(); + }); + + expect(subscriptions.every((entry) => entry.closed === 1)).toBe(true); + + effectiveDirectory = '/switched-after-unmount'; + await runWindowTimeouts(15_000); + expect(openContextPanelTabCalls).toEqual([]); + }); + + test('stops watching running subscriptions when their execution directory leaves the watched set', async () => { + mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', icon: 'build', runIn: 'parent' }]; + effectiveDirectory = '/repo-worktree'; + await renderButton({ projectPath: '/repo', directory: '/repo-worktree' }); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(subscriptions.length).toBeGreaterThan(0); + expect(subscriptions.every((entry) => entry.closed === 0)).toBe(true); + + await renderButton({ projectPath: '/other-repo', directory: '/other-repo' }); + + expect(subscriptions.every((entry) => entry.closed === 1)).toBe(true); + }); + + test('manual action URL does not open a second output-derived URL', async () => { + mockedActionsState.actions = [{ + id: 'build', + name: 'Build', + command: 'echo hello', + icon: 'build', + autoOpenUrl: true, + openUrl: '127.0.0.1:3000', + }]; + await renderButton(); + + const primaryButton = host.querySelector('button'); + if (!primaryButton) { + throw new Error('expected primary button'); + } + + await act(async () => { + primaryButton.dispatchEvent(new Event('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(openContextPreviewCalls).toEqual([{ directory: '/repo', url: 'http://127.0.0.1:3000/' }]); + expect(openExternalCalls).toEqual([]); + + await act(async () => { + emitToSession('session-1', { + type: 'data', + data: 'Server listening at http://127.0.0.1:4000\n', + sequence: 1, + replayData: undefined, + }); + await Promise.resolve(); + }); + + expect(openContextPreviewCalls).toEqual([{ directory: '/repo', url: 'http://127.0.0.1:3000/' }]); + expect(openExternalCalls).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx index dd84620e..197d7afa 100644 --- a/packages/ui/src/components/layout/ProjectActionsButton.tsx +++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx @@ -9,8 +9,10 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; +import type { IconName } from '@/components/icon/icons'; import { cn } from '@/lib/utils'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useDeviceInfo } from '@/lib/device'; import { isDesktopShell } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; @@ -28,15 +30,26 @@ import { } from '@/lib/openchamberConfig'; import { normalizeProjectActionDirectory, + PROJECT_ACTION_ICONS, PROJECT_ACTIONS_UPDATED_EVENT, - PROJECT_ACTION_ICON_MAP, resolveProjectActionDesktopForwardUrl, toProjectActionRunKey, } from '@/lib/projectActions'; import { detectDevServerCommand, readPackageJsonScripts } from '@/lib/detectDevServer'; -import { waitForTerminalExit } from '@/lib/projectActionTerminal'; +import { + createProjectActionTerminalSession, + normalizeProjectActionCommand, + reconcileTerminalSessionAuthority, + stopProjectActionTerminalSession, +} from '@/lib/projectActionTerminal'; +import type { TerminalTab } from '@/stores/useTerminalStore'; type UrlWatchEntry = { + hostDirectory: string; + directory: string; + tabId: string; + actionId: string; + executionId: string; lastSeenChunkId: number | null; openedUrl: boolean; tail: string; @@ -64,20 +77,12 @@ const AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS = 15_000; */ const AUTO_DISCOVER_SETTLE_MS = 3_000; -const stripControlChars = (value: string): string => { - let next = ''; - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - const isControl = (code >= 0 && code <= 8) - || code === 11 - || code === 12 - || (code >= 14 && code <= 31) - || code === 127; - if (!isControl) { - next += value[index]; - } +const resolveProjectActionIconName = (action: Pick): IconName => { + if (action.id === AUTO_DISCOVER_ACTION_ID) { + return 'scan-2'; } - return next; + const matchedIcon = PROJECT_ACTION_ICONS.find((entry) => entry.key === action.icon); + return matchedIcon?.Icon ?? 'play'; }; const normalizeManualOpenUrl = (value: string | undefined): string | null => { @@ -109,6 +114,7 @@ export const ProjectActionsButton = ({ const { t } = useI18n(); const { currentTheme } = useThemeSystem(); const { terminal, runtime } = useRuntimeAPIs(); + const effectiveDirectory = useEffectiveDirectory(); const { isMobile } = useDeviceInfo(); const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []); const desktopSshInstances = useDesktopSshStore((state) => state.instances); @@ -122,26 +128,28 @@ export const ProjectActionsButton = ({ const openContextPreview = useUIStore((state) => state.openContextPreview); const ensureDirectory = useTerminalStore((state) => state.ensureDirectory); + const reconcileServerSessions = useTerminalStore((state) => state.reconcileServerSessions); const setTabLabel = useTerminalStore((state) => state.setTabLabel); const setTabIconKey = useTerminalStore((state) => state.setTabIconKey); const setActiveTab = useTerminalStore((state) => state.setActiveTab); const setConnecting = useTerminalStore((state) => state.setConnecting); const setTabSessionId = useTerminalStore((state) => state.setTabSessionId); + const setTabPurpose = useTerminalStore((state) => state.setTabPurpose); + const allocateActionExecution = useTerminalStore((state) => state.allocateActionExecution); + const setTabLifecycle = useTerminalStore((state) => state.setTabLifecycle); const setTabPreviewUrl = useTerminalStore((state) => state.setTabPreviewUrl); - const projectActionRuns = useTerminalStore((state) => state.projectActionRuns); - const setProjectActionRun = useTerminalStore((state) => state.setProjectActionRun); - const updateProjectActionRunStatus = useTerminalStore((state) => state.updateProjectActionRunStatus); - const removeProjectActionRun = useTerminalStore((state) => state.removeProjectActionRun); + const matchesActionExecution = useTerminalStore((state) => state.matchesActionExecution); + const captureStartedActionMutationRevisions = useTerminalStore((state) => state.captureStartedActionMutationRevisions); const [actions, setActions] = React.useState([]); const [selectedActionId, setSelectedActionId] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); - const tabByKeyRef = React.useRef>({}); const urlWatchByRunKeyRef = React.useRef>({}); const streamCleanupByRunKeyRef = React.useRef void>>({}); const previewWaitTimeoutByRunKeyRef = React.useRef>({}); const startingRunKeysRef = React.useRef>(new Set()); const loadRequestIdRef = React.useRef(0); + const [waitingForPreviewByExecution, setWaitingForPreviewByExecution] = React.useState>({}); const projectId = projectRef?.id ?? null; const projectPath = projectRef?.path ?? ''; @@ -205,6 +213,158 @@ export const ProjectActionsButton = ({ return normalizeProjectActionDirectory(directory || stableProjectRef?.path || ''); }, [directory, stableProjectRef?.path]); + const normalizedProjectDirectory = React.useMemo(() => { + return normalizeProjectActionDirectory(stableProjectRef?.path || ''); + }, [stableProjectRef?.path]); + + const contextHostDirectory = React.useMemo(() => { + return normalizeProjectActionDirectory(effectiveDirectory || '') || normalizedDirectory; + }, [effectiveDirectory, normalizedDirectory]); + const contextHostDirectoryRef = React.useRef(contextHostDirectory); + React.useEffect(() => { + contextHostDirectoryRef.current = contextHostDirectory; + }, [contextHostDirectory]); + + const directoryTerminalState = useTerminalStore((state) => ( + normalizedDirectory ? state.sessions.get(normalizedDirectory) : undefined + )); + + const projectTerminalState = useTerminalStore((state) => ( + normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory + ? state.sessions.get(normalizedProjectDirectory) + : undefined + )); + + const watchedTerminalStates = React.useMemo(() => { + const states = normalizedDirectory + ? [{ directory: normalizedDirectory, state: directoryTerminalState }] + : []; + if (normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory) { + states.push({ directory: normalizedProjectDirectory, state: projectTerminalState }); + } + return states; + }, [directoryTerminalState, normalizedDirectory, normalizedProjectDirectory, projectTerminalState]); + + const watchedTerminalDirectories = React.useMemo(() => { + const directories = normalizedDirectory ? [normalizedDirectory] : []; + if (normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory) { + directories.push(normalizedProjectDirectory); + } + return directories; + }, [normalizedDirectory, normalizedProjectDirectory]); + + const executionDirectoryFor = React.useCallback((action: OpenChamberProjectAction): string => { + if (action.id !== AUTO_DISCOVER_ACTION_ID && action.runIn === 'parent') { + return normalizedProjectDirectory || normalizedDirectory; + } + return normalizedDirectory; + }, [normalizedDirectory, normalizedProjectDirectory]); + + const executionKey = React.useCallback((executionDirectory: string, actionId: string, executionId: string) => ( + `${executionDirectory}::${actionId}::${executionId}` + ), []); + + const getActionTab = React.useCallback((executionDirectory: string, actionId: string, state = useTerminalStore.getState()): TerminalTab | null => { + if (!executionDirectory) return null; + return state.getDirectoryState(executionDirectory)?.tabs.find((tab) => ( + tab.purpose.type === 'project-action' && tab.purpose.actionId === actionId + )) ?? null; + }, []); + + const projectActionRuns = React.useMemo(() => { + const runs: Record = {}; + for (const { directory: tabDirectory, state } of watchedTerminalStates) { + for (const tab of state?.tabs ?? []) { + if (tab.purpose.type !== 'project-action' || !tab.purpose.executionId || !tab.terminalSessionId) continue; + if (tab.lifecycle === 'idle' || tab.lifecycle === 'exited') continue; + const runKey = toProjectActionRunKey(tabDirectory, tab.purpose.actionId); + const execKey = executionKey(tabDirectory, tab.purpose.actionId, tab.purpose.executionId); + runs[runKey] = { + directory: tabDirectory, + actionId: tab.purpose.actionId, + tabId: tab.id, + sessionId: tab.terminalSessionId, + executionId: tab.purpose.executionId, + status: tab.lifecycle === 'stopping' + ? 'stopping' + : waitingForPreviewByExecution[execKey] + ? 'waiting-for-preview' + : 'running', + }; + } + } + return runs; + }, [executionKey, waitingForPreviewByExecution, watchedTerminalStates]); + + const clearExecutionUi = React.useCallback((executionDirectory: string, actionId: string, executionId: string) => { + const actionRunKey = toProjectActionRunKey(executionDirectory, actionId); + const executionStateKey = executionKey(executionDirectory, actionId, executionId); + const watch = urlWatchByRunKeyRef.current[actionRunKey]; + const ownsActionScopedUi = watch?.executionId === executionId; + const browserWindow = globalThis.window; + const clearPreviewWaitTimeout = (key: string) => { + browserWindow?.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]); + delete previewWaitTimeoutByRunKeyRef.current[key]; + }; + + if (ownsActionScopedUi) { + delete urlWatchByRunKeyRef.current[actionRunKey]; + clearPreviewWaitTimeout(actionRunKey); + } + streamCleanupByRunKeyRef.current[executionStateKey]?.(); + delete streamCleanupByRunKeyRef.current[executionStateKey]; + clearPreviewWaitTimeout(executionStateKey); + setWaitingForPreviewByExecution((current) => { + if (!current[executionStateKey]) return current; + const next = { ...current }; + delete next[executionStateKey]; + return next; + }); + }, [executionKey]); + + const closeTrackedSubscription = React.useCallback((executionStateKey: string) => { + streamCleanupByRunKeyRef.current[executionStateKey]?.(); + delete streamCleanupByRunKeyRef.current[executionStateKey]; + }, []); + + const clearTrackedPreviewTimeout = React.useCallback((executionStateKey: string) => { + window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]); + delete previewWaitTimeoutByRunKeyRef.current[executionStateKey]; + }, []); + + React.useEffect(() => { + // The refs hold mutable maps whose identity never changes; reading the + // container once inside the effect keeps the latest entries visible to + // the unmount cleanup without re-reading `.current` there. + const trackedStreams = streamCleanupByRunKeyRef.current; + const trackedTimeouts = previewWaitTimeoutByRunKeyRef.current; + return () => { + for (const executionStateKey of Object.keys(trackedStreams)) { + closeTrackedSubscription(executionStateKey); + } + for (const executionStateKey of Object.keys(trackedTimeouts)) { + clearTrackedPreviewTimeout(executionStateKey); + } + }; + }, [clearTrackedPreviewTimeout, closeTrackedSubscription]); + + React.useEffect(() => { + const watchedDirectories = new Set(watchedTerminalDirectories); + for (const executionStateKey of Object.keys(streamCleanupByRunKeyRef.current)) { + const executionDirectory = executionStateKey.split('::', 1)[0] ?? ''; + if (!watchedDirectories.has(executionDirectory)) { + closeTrackedSubscription(executionStateKey); + } + } + }, [closeTrackedSubscription, watchedTerminalDirectories]); + + const revealProjectActionTerminal = React.useCallback((hostDirectory: string, executionDirectory: string) => { + useUIStore.getState().openContextPanelTab(hostDirectory, { + mode: 'terminal', + targetDirectory: executionDirectory === hostDirectory ? null : executionDirectory, + }); + }, []); + const selectedAction = React.useMemo(() => { if (!selectedActionId) { return null; @@ -231,11 +391,54 @@ export const ProjectActionsButton = ({ }, [loadActions]); React.useEffect(() => { - if (typeof window === 'undefined') { + if (!terminal.listSessions) { + return; + } + let cancelled = false; + for (const executionDirectory of watchedTerminalDirectories) { + void reconcileTerminalSessionAuthority(terminal, executionDirectory, { + captureStartedActionMutationRevisions, + }).then((result) => { + if (cancelled || !result) return; + reconcileServerSessions(executionDirectory, result.sessions, { + startedActionMutationRevisions: result.startedActionMutationRevisions, + }); + }); + } + return () => { + cancelled = true; + }; + }, [captureStartedActionMutationRevisions, reconcileServerSessions, terminal, watchedTerminalDirectories]); + + React.useEffect(() => { + for (const { directory: tabDirectory, state } of watchedTerminalStates) { + if (!tabDirectory) { + continue; + } + for (const tab of state?.tabs ?? []) { + if (tab.purpose.type !== 'project-action') continue; + const actionId = tab.purpose.actionId; + const action = displayActions.find((entry) => entry.id === actionId); + const nextLabel = action?.name ?? actionId; + const nextIcon = action?.icon || 'play'; + if (tab.label !== nextLabel) { + setTabLabel(tabDirectory, tab.id, nextLabel); + } + if (tab.iconKey !== nextIcon) { + setTabIconKey(tabDirectory, tab.id, nextIcon); + } + } + } + }, [displayActions, setTabIconKey, setTabLabel, watchedTerminalStates]); + + React.useEffect(() => { + const browserWindow = globalThis.window; + if (!browserWindow) { return; } const handler = (event: Event) => { + // SAFETY: this event name is only dispatched by our own project-actions update helper with this detail payload. const detail = (event as CustomEvent<{ projectId?: string }>).detail; if (!projectId) { return; @@ -246,9 +449,9 @@ export const ProjectActionsButton = ({ void loadActions(); }; - window.addEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler); + browserWindow.addEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler); return () => { - window.removeEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler); + browserWindow.removeEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler); }; }, [loadActions, projectId]); @@ -276,41 +479,61 @@ export const ProjectActionsButton = ({ const watch = urlWatchByRunKeyRef.current[runKey]; if (!watch || watch.openedUrl) return; - const store = useTerminalStore.getState(); - const run = store.projectActionRuns[runKey]; - if (!run) return; + const executionStateKey = executionKey(watch.directory, watch.actionId, watch.executionId); const candidates = watch.announced; if (candidates.length === 0) return; + window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]); + delete previewWaitTimeoutByRunKeyRef.current[executionStateKey]; watch.openedUrl = true; - store.updateProjectActionRunStatus(runKey, 'running'); + setWaitingForPreviewByExecution((current) => { + if (!current[executionStateKey]) return current; + const next = { ...current }; + delete next[executionStateKey]; + return next; + }); if (candidates.length === 1) { - setAnnouncedDevServers(run.directory, []); - setTabPreviewUrl(run.directory, run.tabId, candidates[0], { locked: false, autoOpened: true }); - openContextPreview(run.directory, candidates[0]); + setAnnouncedDevServers(watch.directory, []); + setTabPreviewUrl(watch.directory, watch.tabId, candidates[0], { locked: false, autoOpened: true }); + openContextPreview(watch.directory, candidates[0]); return; } watch.offering = true; - setAnnouncedDevServers(run.directory, candidates); - useUIStore.getState().openContextSurface(run.directory, 'browser'); + setAnnouncedDevServers(watch.directory, candidates); + useUIStore.getState().openContextSurface(watch.directory, 'browser'); toast.info(t('projectActions.toast.multipleServers')); }; const monitorRuns = () => { const terminalStore = useTerminalStore.getState(); const terminalSessions = terminalStore.sessions; - const currentRuns = terminalStore.projectActionRuns; + const currentRuns = projectActionRuns; for (const [runKey, entry] of Object.entries(currentRuns)) { const directoryState = terminalSessions.get(entry.directory); const tab = directoryState?.tabs.find((item) => item.id === entry.tabId); if (!tab || tab.terminalSessionId !== entry.sessionId) { - removeProjectActionRun(runKey); + clearExecutionUi(entry.directory, entry.actionId, entry.executionId); continue; } - const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false, announced: [], offering: false }; + const existingWatch = urlWatchByRunKeyRef.current[runKey]; + const watch = existingWatch?.executionId === entry.executionId + ? existingWatch + : { + hostDirectory: contextHostDirectoryRef.current || entry.directory, + directory: entry.directory, + tabId: entry.tabId, + actionId: entry.actionId, + executionId: entry.executionId, + lastSeenChunkId: null, + openedUrl: false, + tail: '', + openInPreview: false, + announced: [], + offering: false, + }; urlWatchByRunKeyRef.current[runKey] = watch; const action = displayActions.find((item) => item.id === entry.actionId); const bufferChunks = terminalStore.getBuffer(entry.directory, entry.tabId).chunks; @@ -359,8 +582,16 @@ export const ProjectActionsButton = ({ if (watch.openInPreview) { const run = currentRuns[runKey]; if (run) { - setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false }); - if (run.status === 'waiting-for-preview') updateProjectActionRunStatus(runKey, 'running'); + setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false, expectedExecutionId: run.executionId }); + if (run.status === 'waiting-for-preview') { + setWaitingForPreviewByExecution((current) => { + const executionStateKey = executionKey(run.directory, run.actionId, run.executionId); + if (!current[executionStateKey]) return current; + const next = { ...current }; + delete next[executionStateKey]; + return next; + }); + } window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); delete previewWaitTimeoutByRunKeyRef.current[runKey]; openContextPreview(run.directory, maybeUrl); @@ -375,6 +606,18 @@ export const ProjectActionsButton = ({ for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) { if (!currentRuns[runKey]) { + const watch = urlWatchByRunKeyRef.current[runKey]; + const currentTab = watch + ? terminalSessions.get(watch.directory)?.tabs.find((tab) => tab.id === watch.tabId) + : undefined; + const watchStillOwnedByActiveExecution = currentTab?.purpose.type === 'project-action' + && currentTab.purpose.executionId === watch?.executionId + && Boolean(currentTab.terminalSessionId) + && currentTab.lifecycle !== 'idle' + && currentTab.lifecycle !== 'exited'; + if (watchStillOwnedByActiveExecution) { + continue; + } delete urlWatchByRunKeyRef.current[runKey]; window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); delete previewWaitTimeoutByRunKeyRef.current[runKey]; @@ -386,49 +629,91 @@ export const ProjectActionsButton = ({ return useTerminalStore.subscribe((state, previousState) => { if (state.sessions !== previousState.sessions || state.buffers !== previousState.buffers) monitorRuns(); }); - }, [displayActions, openContextPreview, openExternal, projectActionRuns, removeProjectActionRun, setTabPreviewUrl, t, updateProjectActionRunStatus]); + }, [clearExecutionUi, contextHostDirectoryRef, displayActions, executionKey, openContextPreview, openExternal, projectActionRuns, setTabPreviewUrl, t]); - const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction, options: { revealTerminal?: boolean } = {}) => { - if (!normalizedDirectory) { + React.useEffect(() => { + for (const { directory: tabDirectory, state } of watchedTerminalStates) { + for (const tab of state?.tabs ?? []) { + if (tab.purpose.type !== 'project-action' || !tab.purpose.executionId || !tab.terminalSessionId) continue; + if (tab.lifecycle !== 'running') continue; + const actionId = tab.purpose.actionId; + const currentExecutionId = tab.purpose.executionId; + const streamKey = executionKey(tabDirectory, actionId, currentExecutionId); + if (streamCleanupByRunKeyRef.current[streamKey]) continue; + const subscription = terminal.connect(tab.terminalSessionId, { + onEvent: (event) => { + if (!matchesActionExecution(tabDirectory, tab.id, currentExecutionId)) return; + if (event.type === 'snapshot') { + useTerminalStore.getState().replaceBuffer(tabDirectory, tab.id, event.data ?? '', event.sequence ?? 0); + if (event.status === 'running') { + useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'running', { expectedExecutionId: currentExecutionId }); + } + if (event.status === 'exited') { + useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'exited', { expectedExecutionId: currentExecutionId }); + useTerminalStore.getState().setTabPurpose(tabDirectory, tab.id, { type: 'project-action', actionId, executionId: null }); + clearExecutionUi(tabDirectory, actionId, currentExecutionId); + } + } + const output = event.type === 'data' ? (event.data ?? '') : ''; + if (output) { + useTerminalStore.getState().appendToBuffer(tabDirectory, tab.id, output, event.sequence, event.replayData); + } + if (event.type === 'exit') { + useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'exited', { expectedExecutionId: currentExecutionId }); + useTerminalStore.getState().setTabPurpose(tabDirectory, tab.id, { type: 'project-action', actionId, executionId: null }); + clearExecutionUi(tabDirectory, actionId, currentExecutionId); + } + }, + onError: (_error, fatal) => { + if (!fatal || !matchesActionExecution(tabDirectory, tab.id, currentExecutionId)) return; + useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'exited', { expectedExecutionId: currentExecutionId }); + useTerminalStore.getState().setTabSessionId(tabDirectory, tab.id, null, { expectedExecutionId: currentExecutionId }); + useTerminalStore.getState().setTabPurpose(tabDirectory, tab.id, { type: 'project-action', actionId, executionId: null }); + clearExecutionUi(tabDirectory, actionId, currentExecutionId); + }, + }); + streamCleanupByRunKeyRef.current[streamKey] = subscription.close; + } + } + }, [clearExecutionUi, executionKey, matchesActionExecution, terminal, watchedTerminalStates]); + + const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction) => { + const executionDirectory = executionDirectoryFor(action); + if (!executionDirectory) { throw new Error(t('projectActions.error.noActiveDirectory')); } - const key = toProjectActionRunKey(normalizedDirectory, action.id); - ensureDirectory(normalizedDirectory); + const key = toProjectActionRunKey(executionDirectory, action.id); + ensureDirectory(executionDirectory); const currentStore = useTerminalStore.getState(); - const existingDirectoryState = currentStore.getDirectoryState(normalizedDirectory); + const existingTab = getActionTab(executionDirectory, action.id, currentStore); + const tabId = existingTab?.id ?? currentStore.createTab(executionDirectory); - let tabId = tabByKeyRef.current[key] || null; - const hasTab = tabId - ? Boolean(existingDirectoryState?.tabs.some((entry) => entry.id === tabId)) - : false; - - if (!tabId || !hasTab) { - tabId = currentStore.createTab(normalizedDirectory); - tabByKeyRef.current[key] = tabId; + setTabLabel(executionDirectory, tabId, action.name); + setTabIconKey(executionDirectory, tabId, action.icon || 'play'); + if (!existingTab) { + setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: action.id, executionId: null }); } + setActiveTab(executionDirectory, tabId); - setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`); - setTabIconKey(normalizedDirectory, tabId, action.icon || 'play'); - setActiveTab(normalizedDirectory, tabId); - if (options.revealTerminal !== false) { - useUIStore.getState().openContextPanelTab(normalizedDirectory, { mode: 'terminal' }); - } - - const stateAfterTab = useTerminalStore.getState().getDirectoryState(normalizedDirectory); + const stateAfterTab = useTerminalStore.getState().getDirectoryState(executionDirectory); const tab = stateAfterTab?.tabs.find((entry) => entry.id === tabId); return { + executionDirectory, key, tabId, sessionId: tab?.terminalSessionId ?? null, + executionId: tab?.purpose.type === 'project-action' ? tab.purpose.executionId : null, }; }, [ ensureDirectory, - normalizedDirectory, + executionDirectoryFor, + getActionTab, setActiveTab, setTabIconKey, setTabLabel, + setTabPurpose, t, ]); @@ -442,7 +727,7 @@ export const ProjectActionsButton = ({ return; } - const runKey = toProjectActionRunKey(normalizedDirectory, action.id); + const runKey = toProjectActionRunKey(executionDirectoryFor(action), action.id); const existingRun = projectActionRuns[runKey]; if (existingRun && existingRun.status === 'running') { return; @@ -474,65 +759,13 @@ export const ProjectActionsButton = ({ const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0; const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID; - const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal }); - let activeSessionId = sessionId; - - if (!activeSessionId) { - setConnecting(normalizedDirectory, tabId, true); - try { - const created = await terminal.createSession({ - cwd: normalizedDirectory, - sessionId: tabId, - shell: terminalShell, - loginShell: terminalLoginShell, - themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', - terminalBackground: currentTheme.colors.surface.background, - terminalForeground: currentTheme.colors.syntax.base.foreground, - }); - activeSessionId = created.sessionId; - setTabSessionId(normalizedDirectory, tabId, activeSessionId); - } finally { - setConnecting(normalizedDirectory, tabId, false); - } + const launchContextHostDirectory = contextHostDirectoryRef.current || normalizedDirectory; + const { executionDirectory, key, tabId, sessionId } = await getOrCreateActionTab(discovered); + const normalizedCommand = normalizeProjectActionCommand(discovered.command); + if (!normalizedCommand) { + throw new Error(t('projectActions.error.failedToRunAction')); } - if (!activeSessionId) { - throw new Error(t('projectActions.error.failedToCreateTerminalSession')); - } - - streamCleanupByRunKeyRef.current[key]?.(); - setConnecting(normalizedDirectory, tabId, true); - const subscription = terminal.connect( - activeSessionId, - { onEvent: (event) => { - if (event.type === 'snapshot') { - useTerminalStore.getState().replaceBuffer(normalizedDirectory, tabId, event.data ?? '', event.sequence ?? 0); - useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false); - } - if (event.type === 'data' && typeof event.data === 'string' && event.data.length > 0) { - useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data, event.sequence, event.replayData); - } - if (event.type === 'exit') { - useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited'); - useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false); - useTerminalStore.getState().removeProjectActionRun(key); - delete urlWatchByRunKeyRef.current[key]; - streamCleanupByRunKeyRef.current[key]?.(); - delete streamCleanupByRunKeyRef.current[key]; - window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]); - delete previewWaitTimeoutByRunKeyRef.current[key]; - } - }, onError: (_error, fatal) => { - useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false); - if (fatal) { - useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited'); - useTerminalStore.getState().setTabSessionId(normalizedDirectory, tabId, null); - useTerminalStore.getState().removeProjectActionRun(key); - } - } }, - ); - streamCleanupByRunKeyRef.current[key] = subscription.close; - const hasDesktopForwardSelection = discovered.autoOpenUrl === true && isDesktopShellApp && (discovered.desktopOpenSshForward || '').trim().length > 0; @@ -541,30 +774,157 @@ export const ProjectActionsButton = ({ ? resolveProjectActionDesktopForwardUrl(discovered.desktopOpenSshForward, desktopSshInstances) : null; - setProjectActionRun({ - key, - directory: normalizedDirectory, - actionId: discovered.id, - tabId, - sessionId: activeSessionId, - status: discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl ? 'waiting-for-preview' : 'running', - }); - window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]); - delete previewWaitTimeoutByRunKeyRef.current[key]; - if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) { - previewWaitTimeoutByRunKeyRef.current[key] = window.setTimeout(() => { - const store = useTerminalStore.getState(); - const run = store.projectActionRuns[key]; - store.updateProjectActionRunStatus(key, 'running'); - if (run) { - store.setActiveTab(run.directory, run.tabId); - useUIStore.getState().openContextPanelTab(run.directory, { mode: 'terminal' }); + if (terminal.listSessions) { + const currentTab = getActionTab(executionDirectory, discovered.id); + if (currentTab?.purpose.type === 'project-action' && currentTab.purpose.executionId === null) { + const result = await reconcileTerminalSessionAuthority(terminal, executionDirectory, { + captureStartedActionMutationRevisions, + }); + if (result) { + reconcileServerSessions(executionDirectory, result.sessions, { + startedActionMutationRevisions: result.startedActionMutationRevisions, + }); } - delete previewWaitTimeoutByRunKeyRef.current[key]; + } + } + + const priorTab = getActionTab(executionDirectory, discovered.id); + const priorExecutionId = priorTab?.purpose.type === 'project-action' ? priorTab.purpose.executionId : null; + if (priorExecutionId) { + clearExecutionUi(executionDirectory, discovered.id, priorExecutionId); + } + + const requestedExecutionId = allocateActionExecution(executionDirectory, tabId, discovered.id); + if (!requestedExecutionId) { + throw new Error(t('projectActions.error.failedToCreateTerminalSession')); + } + + setConnecting(executionDirectory, tabId, true, { expectedExecutionId: requestedExecutionId }); + let activeSessionId: string | null = null; + let adoptedExecutionId = requestedExecutionId; + try { + const created = await createProjectActionTerminalSession({ + terminal, + previousSessionId: sessionId, + createOptions: { + cwd: executionDirectory, + sessionId: tabId, + shell: terminalShell, + loginShell: terminalLoginShell, + themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', + terminalBackground: currentTheme.colors.surface.background, + terminalForeground: currentTheme.colors.syntax.base.foreground, + }, + command: normalizedCommand, + isRunStillExpected: () => matchesActionExecution(executionDirectory, tabId, requestedExecutionId), + purpose: { type: 'project-action', actionId: discovered.id, executionId: requestedExecutionId }, + }); + if (!matchesActionExecution(executionDirectory, tabId, requestedExecutionId)) { + await terminal.close(created.sessionId).catch(() => undefined); + return; + } + adoptedExecutionId = created.purpose?.type === 'project-action' ? created.purpose.executionId : requestedExecutionId; + setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: adoptedExecutionId }); + activeSessionId = created.sessionId; + setTabSessionId(executionDirectory, tabId, activeSessionId, { expectedExecutionId: adoptedExecutionId }); + setTabLifecycle(executionDirectory, tabId, 'running', { expectedExecutionId: adoptedExecutionId }); + } finally { + setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); + } + + if (!activeSessionId) { + throw new Error(t('projectActions.error.failedToCreateTerminalSession')); + } + + if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) { + try { + await terminal.close(activeSessionId); + } catch { + // noop + } + return; + } + + if (revealTerminal && launchContextHostDirectory) { + revealProjectActionTerminal(launchContextHostDirectory, executionDirectory); + } + + const executionStateKey = executionKey(executionDirectory, discovered.id, adoptedExecutionId); + setConnecting(executionDirectory, tabId, true, { expectedExecutionId: adoptedExecutionId }); + const subscription = terminal.connect( + activeSessionId, + { onEvent: (event) => { + if (event.type === 'snapshot') { + useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0); + useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); + if (event.purpose?.type === 'project-action') { + useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: event.purpose.actionId, executionId: event.purpose.executionId }); + } + if (event.status === 'running') { + useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'running', { expectedExecutionId: adoptedExecutionId }); + } + if (event.status === 'exited') { + useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId }); + useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: null }); + clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId); + } + } + const output = event.type === 'data' ? (event.data ?? '') : ''; + if (output) { + useTerminalStore.getState().appendToBuffer(executionDirectory, tabId, output, event.sequence, event.replayData); + } + if (event.type === 'exit') { + useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId }); + useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); + useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: null }); + clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId); + } + }, onError: (_error, fatal) => { + useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); + if (fatal) { + useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId }); + useTerminalStore.getState().setTabSessionId(executionDirectory, tabId, null, { expectedExecutionId: adoptedExecutionId }); + useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: null }); + clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId); + } + } }, + ); + if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) { + subscription.close(); + return; + } + streamCleanupByRunKeyRef.current[executionStateKey] = subscription.close; + + window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]); + delete previewWaitTimeoutByRunKeyRef.current[executionStateKey]; + if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) { + setWaitingForPreviewByExecution((current) => ({ ...current, [executionStateKey]: true })); + previewWaitTimeoutByRunKeyRef.current[executionStateKey] = window.setTimeout(() => { + delete previewWaitTimeoutByRunKeyRef.current[executionStateKey]; + const watch = urlWatchByRunKeyRef.current[key]; + if (!watch || watch.executionId !== adoptedExecutionId || watch.openedUrl || watch.offering) { + return; + } + if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) { + return; + } + setWaitingForPreviewByExecution((current) => { + if (!current[executionStateKey]) return current; + const next = { ...current }; + delete next[executionStateKey]; + return next; + }); + useTerminalStore.getState().setActiveTab(executionDirectory, tabId); + revealProjectActionTerminal(watch.hostDirectory, executionDirectory); }, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS); } urlWatchByRunKeyRef.current[key] = { + hostDirectory: launchContextHostDirectory, + directory: executionDirectory, + tabId, + actionId: discovered.id, + executionId: adoptedExecutionId, lastSeenChunkId: null, openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl, tail: '', @@ -573,34 +933,39 @@ export const ProjectActionsButton = ({ offering: false, }; - const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n')); - await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`); - if (desktopForwardUrl) { - setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true }); + setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId }); void openExternal(desktopForwardUrl); toast.success(t('projectActions.toast.openedForwardedUrl')); } else if (manualOpenUrl) { - setTabPreviewUrl(normalizedDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true }); - openContextPreview(normalizedDirectory, manualOpenUrl); + setTabPreviewUrl(executionDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true, expectedExecutionId: adoptedExecutionId }); + openContextPreview(executionDirectory, manualOpenUrl); toast.success(t('projectActions.toast.openedActionUrl')); } else if (hasCustomOpenUrl) { - setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true }); + setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId }); toast.error(t('projectActions.error.invalidCustomUrlFormat')); } else if (hasDesktopForwardSelection) { - setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true }); + setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId }); toast.error(t('projectActions.error.selectedDesktopSshForwardUnavailable')); } else { - setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: false, autoOpened: false }); + setTabPreviewUrl(executionDirectory, tabId, null, { locked: false, autoOpened: false, expectedExecutionId: adoptedExecutionId }); } } catch (error) { - removeProjectActionRun(runKey); - delete urlWatchByRunKeyRef.current[runKey]; - streamCleanupByRunKeyRef.current[runKey]?.(); - delete streamCleanupByRunKeyRef.current[runKey]; - window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); - delete previewWaitTimeoutByRunKeyRef.current[runKey]; + const executionDirectory = executionDirectoryFor(action); + const currentTab = getActionTab(executionDirectory, action.id); + if (currentTab?.purpose.type === 'project-action' && currentTab.purpose.executionId) { + clearExecutionUi(executionDirectory, action.id, currentTab.purpose.executionId); + setTabLifecycle(executionDirectory, currentTab.id, 'exited', { expectedExecutionId: currentTab.purpose.executionId }); + setTabPurpose(executionDirectory, currentTab.id, { type: 'project-action', actionId: action.id, executionId: null }); + } + if (error instanceof Error && error.message === 'PROJECT_ACTION_RUN_CANCELLED') { + return; + } + if (error instanceof Error && (error.message === 'COMMAND_MODE_UNSUPPORTED' || error.message === 'PROJECT_ACTION_PURPOSE_UNSUPPORTED')) { + toast.error(t('projectActions.error.failedToCreateTerminalSession')); + return; + } toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction')); } finally { startingRunKeysRef.current.delete(runKey); @@ -609,6 +974,7 @@ export const ProjectActionsButton = ({ currentTheme.colors.surface.background, currentTheme.colors.syntax.base.foreground, currentTheme.metadata.variant, + contextHostDirectoryRef, desktopSshInstances, getOrCreateActionTab, allowMobile, @@ -620,10 +986,19 @@ export const ProjectActionsButton = ({ openExternal, openContextPreview, projectActionRuns, + revealProjectActionTerminal, runtime.isVSCode, - removeProjectActionRun, + executionDirectoryFor, + matchesActionExecution, + clearExecutionUi, + executionKey, + getActionTab, + reconcileServerSessions, + allocateActionExecution, + captureStartedActionMutationRevisions, setConnecting, - setProjectActionRun, + setTabLifecycle, + setTabPurpose, setTabPreviewUrl, setTabSessionId, stableProjectRef?.id, @@ -632,60 +1007,39 @@ export const ProjectActionsButton = ({ ]); const stopAction = React.useCallback(async (action: OpenChamberProjectAction) => { - const runKey = toProjectActionRunKey(normalizedDirectory, action.id); + const runKey = toProjectActionRunKey(executionDirectoryFor(action), action.id); const activeRun = projectActionRuns[runKey]; if (!activeRun) { return; } - updateProjectActionRunStatus(runKey, 'stopping'); - - const exitPromise = waitForTerminalExit(terminal, activeRun.sessionId, 1000); - - try { - await terminal.sendInput(activeRun.sessionId, '\x03'); - } catch { - // noop - } - - const exitObserved = await exitPromise; - - const afterTab = useTerminalStore.getState().getDirectoryState(activeRun.directory)?.tabs - .find((entry) => entry.id === activeRun.tabId); - - const sessionStillSame = afterTab?.terminalSessionId === activeRun.sessionId; - - if (sessionStillSame && !exitObserved) { - if (typeof terminal.forceKill === 'function') { - try { - await terminal.forceKill({ sessionId: activeRun.sessionId }); - } catch { - // noop - } - } else { - try { - await terminal.close(activeRun.sessionId); - } catch { - // noop - } - } - setTabSessionId(activeRun.directory, activeRun.tabId, null); - } - - removeProjectActionRun(runKey); - delete urlWatchByRunKeyRef.current[runKey]; - streamCleanupByRunKeyRef.current[runKey]?.(); - delete streamCleanupByRunKeyRef.current[runKey]; - window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); - delete previewWaitTimeoutByRunKeyRef.current[runKey]; - }, [normalizedDirectory, projectActionRuns, removeProjectActionRun, setTabSessionId, terminal, updateProjectActionRunStatus]); + await stopProjectActionTerminalSession({ + terminal, + sessionId: activeRun.sessionId, + isExecutionStillCurrent: () => matchesActionExecution(activeRun.directory, activeRun.tabId, activeRun.executionId), + markStopping: () => { + setTabLifecycle(activeRun.directory, activeRun.tabId, 'stopping', { expectedExecutionId: activeRun.executionId }); + }, + restoreRunning: () => { + setTabLifecycle(activeRun.directory, activeRun.tabId, 'running', { expectedExecutionId: activeRun.executionId }); + }, + clearSession: () => { + setTabSessionId(activeRun.directory, activeRun.tabId, null, { expectedExecutionId: activeRun.executionId }); + }, + finalizeExit: () => { + setTabLifecycle(activeRun.directory, activeRun.tabId, 'exited', { expectedExecutionId: activeRun.executionId }); + setTabPurpose(activeRun.directory, activeRun.tabId, { type: 'project-action', actionId: activeRun.actionId, executionId: null }); + clearExecutionUi(activeRun.directory, activeRun.actionId, activeRun.executionId); + }, + }); + }, [clearExecutionUi, executionDirectoryFor, matchesActionExecution, projectActionRuns, setTabLifecycle, setTabPurpose, setTabSessionId, terminal]); const handlePrimaryClick = React.useCallback(() => { const action = selectedAction ?? displayActions[0]; if (!action) { return; } - const runKey = toProjectActionRunKey(normalizedDirectory, action.id); + const runKey = toProjectActionRunKey(executionDirectoryFor(action), action.id); const runningEntry = projectActionRuns[runKey]; if (runningEntry?.status === 'stopping') { return; @@ -695,7 +1049,7 @@ export const ProjectActionsButton = ({ return; } void runAction(action); - }, [displayActions, normalizedDirectory, runAction, projectActionRuns, selectedAction, stopAction]); + }, [displayActions, executionDirectoryFor, runAction, projectActionRuns, selectedAction, stopAction]); const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => { setSelectedActionId(action.id); @@ -705,7 +1059,7 @@ export const ProjectActionsButton = ({ return; } - const runKey = toProjectActionRunKey(normalizedDirectory, action.id); + const runKey = toProjectActionRunKey(executionDirectoryFor(action), action.id); const runningEntry = projectActionRuns[runKey]; if (runningEntry?.status === 'stopping') { return; @@ -715,7 +1069,7 @@ export const ProjectActionsButton = ({ return; } void runAction(action); - }, [normalizedDirectory, runAction, projectActionRuns, stopAction]); + }, [executionDirectoryFor, runAction, projectActionRuns, stopAction]); const openProjectActionsSettings = React.useCallback(() => { if (!stableProjectRef?.id) { @@ -727,7 +1081,7 @@ export const ProjectActionsButton = ({ }, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]); const previewAction = selectedAction ?? displayActions[0] ?? null; - const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(normalizedDirectory, previewAction.id)] : null; + const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(executionDirectoryFor(previewAction), previewAction.id)] : null; const selectedRunPreviewUrl = useTerminalStore((state) => { if (!previewRun) return null; return state.sessions.get(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null; @@ -742,11 +1096,8 @@ export const ProjectActionsButton = ({ return null; } - const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP; - const selectedIconName = resolvedSelected.id === AUTO_DISCOVER_ACTION_ID - ? 'scan-2' - : PROJECT_ACTION_ICON_MAP[selectedIconKey] || 'play'; - const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id); + const selectedIconName = resolveProjectActionIconName(resolvedSelected); + const selectedRunKey = toProjectActionRunKey(executionDirectoryFor(resolvedSelected), resolvedSelected.id); const selectedRunning = projectActionRuns[selectedRunKey]; const isStoppingSelected = selectedRunning?.status === 'stopping'; const isWaitingForSelectedPreview = selectedRunning?.status === 'waiting-for-preview'; @@ -822,11 +1173,8 @@ export const ProjectActionsButton = ({ {displayActions.map((entry) => { - const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP; - const iconName = entry.id === AUTO_DISCOVER_ACTION_ID - ? 'scan-2' - : PROJECT_ACTION_ICON_MAP[iconKey] || 'play'; - const runKey = toProjectActionRunKey(normalizedDirectory, entry.id); + const iconName = resolveProjectActionIconName(entry); + const runKey = toProjectActionRunKey(executionDirectoryFor(entry), entry.id); const runState = projectActionRuns[runKey]; const isRunning = Boolean(runState); const isStopping = runState?.status === 'stopping'; @@ -935,11 +1283,8 @@ export const ProjectActionsButton = ({ {displayActions.map((entry) => { - const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP; - const iconName = entry.id === AUTO_DISCOVER_ACTION_ID - ? 'scan-2' - : PROJECT_ACTION_ICON_MAP[iconKey] || 'play'; - const runKey = toProjectActionRunKey(normalizedDirectory, entry.id); + const iconName = resolveProjectActionIconName(entry); + const runKey = toProjectActionRunKey(executionDirectoryFor(entry), entry.id); const runState = projectActionRuns[runKey]; const isRunning = Boolean(runState); const isStopping = runState?.status === 'stopping'; diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 4433e3b0..90a6bad2 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -27,6 +27,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useTerminalSessionKeepalive } from '@/hooks/useTerminalSessionKeepalive'; import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useI18n } from '@/lib/i18n'; import { toast } from '@/components/ui'; @@ -529,6 +530,7 @@ export const VSCodeLayout: React.FC = () => { }, [usesExpandedLayout, currentView, viewMode]); useSessionListSync({ isVSCode: true }); + useTerminalSessionKeepalive(); return ( <> diff --git a/packages/ui/src/components/layout/__tests__/contextPanelTerminalTarget.test.ts b/packages/ui/src/components/layout/__tests__/contextPanelTerminalTarget.test.ts new file mode 100644 index 00000000..2dc29729 --- /dev/null +++ b/packages/ui/src/components/layout/__tests__/contextPanelTerminalTarget.test.ts @@ -0,0 +1,36 @@ +/** + * Regression guard for persisted context-panel terminal targets. + * + * The context panel keeps the singleton terminal pane mounted even when another + * context tab is active. The mounted `TerminalView` must therefore receive its + * directory from the stored terminal tab itself, not from whichever tab is + * currently active. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8'); + +describe('context panel terminal target wiring', () => { + test('keeps a singleton terminal tab lookup independent of the active tab', () => { + expect(contextPanelSource).toContain('const terminalTab = React.useMemo('); + expect(contextPanelSource).toContain("tabs.find((tab) => tab.mode === 'terminal')"); + expect(contextPanelSource).not.toContain('const hasTerminalTab = React.useMemo('); + }); + + test('passes the stored terminal targetDirectory into the mounted TerminalView', () => { + const renderStart = contextPanelSource.indexOf('{terminalTab ? ('); + expect(renderStart).toBeGreaterThan(-1); + const renderEnd = contextPanelSource.indexOf('{hasWalkthroughTab ? (', renderStart); + expect(renderEnd).toBeGreaterThan(renderStart); + const renderBlock = contextPanelSource.slice(renderStart, renderEnd); + + expect(renderBlock).toContain("activeTab?.mode === 'terminal' ? 'block' : 'hidden'"); + expect(renderBlock).toContain(""); + expect(renderBlock).not.toContain('directory={activeTab?.targetDirectory}'); + expect(renderBlock).not.toContain('directory={effectiveDirectory}'); + }); +}); diff --git a/packages/ui/src/components/sections/projects/ProjectActionsSection.test.tsx b/packages/ui/src/components/sections/projects/ProjectActionsSection.test.tsx new file mode 100644 index 00000000..d8f9ce24 --- /dev/null +++ b/packages/ui/src/components/sections/projects/ProjectActionsSection.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Window } from 'happy-dom'; + +import { I18nProvider } from '@/lib/i18n'; + +const desktopSshState = { instances: [], load: async () => undefined }; + +mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false })); +mock.module('@/stores/useDesktopSshStore', () => ({ + useDesktopSshStore: (selector: (state: typeof desktopSshState) => T): T => selector(desktopSshState), +})); +mock.module('@/lib/openchamberConfig', () => ({ + getProjectActionsState: async () => ({ + actions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build' }], + primaryActionId: null, + }), + saveProjectActionsState: async () => true, +})); + +const { ProjectActionsSection } = await import('./ProjectActionsSection'); + +describe('ProjectActionsSection', () => { + let windowInstance: Window; + let root: Root; + let host: HTMLDivElement; + + beforeEach(() => { + windowInstance = new Window({ url: 'http://localhost/' }); + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + navigator: windowInstance.navigator, + Node: windowInstance.Node, + Element: windowInstance.Element, + HTMLElement: windowInstance.HTMLElement, + Event: windowInstance.Event, + MouseEvent: windowInstance.MouseEvent, + MutationObserver: windowInstance.MutationObserver, + getComputedStyle: windowInstance.getComputedStyle.bind(windowInstance), + requestAnimationFrame: windowInstance.requestAnimationFrame.bind(windowInstance), + cancelAnimationFrame: windowInstance.cancelAnimationFrame.bind(windowInstance), + IS_REACT_ACT_ENVIRONMENT: true, + }); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + windowInstance.close(); + }); + + test('shows the current worktree label when runIn is omitted', async () => { + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + + const actionTrigger = Array.from(host.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Build')); + if (!actionTrigger) { + throw new Error('expected saved action trigger'); + } + + await act(async () => { + actionTrigger.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const runInTrigger = host.querySelector('button[aria-label="Working directory for this action"]'); + expect(runInTrigger?.textContent).toContain('Current worktree'); + expect(runInTrigger?.textContent).not.toContain('__project__'); + }); +}); diff --git a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx index 91c99f44..62ef4270 100644 --- a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx +++ b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx @@ -21,7 +21,7 @@ import { import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; -import { Icon } from "@/components/icon/Icon"; +import { Icon } from '@/components/icon/Icon'; import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; import { isDesktopShell } from '@/lib/desktop'; import { @@ -40,7 +40,10 @@ import { PROJECT_SETTINGS_CONTROL_WIDTH, ProjectSettingsSubsection, } from '@/components/sections/projects/ProjectSettingsSubsection'; -import { SETTINGS_SELECT_SIZE } from '@/components/sections/shared/SettingsSection'; +import { + SETTINGS_SELECT_SIZE, + SETTINGS_SELECT_TRIGGER_CLASS, +} from '@/components/sections/shared/SettingsSection'; import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; @@ -48,6 +51,7 @@ import { cn } from '@/lib/utils'; type EditableProjectAction = OpenChamberProjectAction; const AUTO_SAVE_DELAY_MS = 450; +const PROJECT_RUN_IN_PARENT_VALUE = '__project__'; const createActionId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { @@ -342,6 +346,43 @@ export const ProjectActionsSection: React.FC = ({ pr /> +
+
+

{t('settings.projects.actions.runIn.label')}

+ + {t('settings.projects.actions.runIn.info')} + +
+ +
+
{t('settings.projects.actions.field.autoOpenUrl')} diff --git a/packages/ui/src/components/terminal/TerminalViewport.test.tsx b/packages/ui/src/components/terminal/TerminalViewport.test.tsx new file mode 100644 index 00000000..b7f83b7c --- /dev/null +++ b/packages/ui/src/components/terminal/TerminalViewport.test.tsx @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Window } from 'happy-dom'; + +import { useTerminalStore, type TerminalChunk } from '@/stores/useTerminalStore'; + +const terminalEvents: Array<{ type: 'write'; data: string } | { type: 'reset' }> = []; + +class GhosttyTerminalDouble { + public options: { cursorBlink: boolean }; + public cols = 80; + public rows = 24; + + constructor(options: { cursorBlink?: boolean }) { + this.options = { cursorBlink: options.cursorBlink ?? false }; + } + + loadAddon() {} + open() {} + onData() { + return { dispose() {} }; + } + write(data: string, callback?: () => void) { + terminalEvents.push({ type: 'write', data }); + callback?.(); + } + reset() { + terminalEvents.push({ type: 'reset' }); + } + focus() {} + dispose() {} +} + +class FitAddonDouble { + fit() {} +} + +mock.module('ghostty-web', () => ({ + Ghostty: { load: async () => ({}) }, + Terminal: GhosttyTerminalDouble, + FitAddon: FitAddonDouble, +})); + +const { TerminalViewport } = await import('./TerminalViewport'); + +const theme = { + background: '#000000', + foreground: '#ffffff', + cursor: '#ffffff', + cursorAccent: '#000000', + selectionBackground: '#334155', + selectionForeground: '#ffffff', + black: '#111111', + red: '#ff0000', + green: '#00ff00', + yellow: '#ffff00', + blue: '#0000ff', + magenta: '#ff00ff', + cyan: '#00ffff', + white: '#ffffff', + brightBlack: '#666666', + brightRed: '#ff0000', + brightGreen: '#00ff00', + brightYellow: '#ffff00', + brightBlue: '#0000ff', + brightMagenta: '#ff00ff', + brightCyan: '#00ffff', + brightWhite: '#ffffff', +} as const; + +const flushGhosttyLoad = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const TERMINAL_BUFFER_CAP = 512 * 1024; + +const replayWriteEvents = (expectedPayloads: string[]) => terminalEvents.filter( + (event): event is { type: 'write'; data: string } => event.type === 'write' && expectedPayloads.includes(event.data), +); + +const buildReplacedBufferChunks = (content: string): TerminalChunk[] => { + const directory = '/fixture'; + useTerminalStore.getState().clearAll(); + useTerminalStore.getState().ensureDirectory(directory); + const tabId = useTerminalStore.getState().getDirectoryState(directory)?.tabs[0]?.id; + if (!tabId) throw new Error('fixture tab missing'); + useTerminalStore.getState().replaceBuffer(directory, tabId, content, 1); + return [...useTerminalStore.getState().getBuffer(directory, tabId).chunks]; +}; + +const renderViewport = (root: Root, chunks: TerminalChunk[]) => act(async () => { + root.render( + undefined} + onResize={() => undefined} + theme={theme} + monoFont="geist-mono" + fontFamily="Geist Mono" + fontSize={14} + />, + ); +}); + +describe('TerminalViewport chunk replay integration', () => { + let windowInstance: Window; + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + terminalEvents.length = 0; + useTerminalStore.getState().clearAll(); + windowInstance = new Window({ url: 'http://localhost/' }); + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + navigator: windowInstance.navigator, + HTMLElement: windowInstance.HTMLElement, + Element: windowInstance.Element, + Node: windowInstance.Node, + Event: windowInstance.Event, + InputEvent: windowInstance.InputEvent, + KeyboardEvent: windowInstance.KeyboardEvent, + MouseEvent: windowInstance.MouseEvent, + FocusEvent: windowInstance.FocusEvent, + ResizeObserver: class { + observe() {} + disconnect() {} + }, + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + cancelAnimationFrame: () => undefined, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(windowInstance.document, 'hasFocus', { + configurable: true, + value: () => true, + }); + Object.defineProperty(windowInstance.HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value() { + return { x: 0, y: 0, top: 0, left: 0, right: 800, bottom: 600, width: 800, height: 600 }; + }, + }); + + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + useTerminalStore.getState().clearAll(); + }); + + test('would fail if adopted-buffer remount replay split history writes or exceeded the capped buffer payload', async () => { + const replayChunks: TerminalChunk[] = [ + { id: 1, data: 'live-one\n', replayData: 'replay-one\n', byteLength: 9 }, + { id: 2, data: 'live-two\n', replayData: 'replay-two\n', byteLength: 9 }, + { id: 3, data: 'live-three\n', byteLength: 11 }, + ]; + const replayPayload = 'replay-one\nreplay-two\nlive-three\n'; + + await renderViewport(root, replayChunks); + await flushGhosttyLoad(); + + expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(0); + expect(replayWriteEvents([replayPayload])).toEqual([{ type: 'write', data: replayPayload }]); + + await act(async () => root.unmount()); + host.remove(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + terminalEvents.length = 0; + + const oversizedReplayChunks = buildReplacedBufferChunks(`${'🙂'.repeat(180_000)}tail`); + const oversizedPayload = oversizedReplayChunks.map((chunk) => chunk.data).join(''); + + await renderViewport(root, oversizedReplayChunks); + await flushGhosttyLoad(); + + expect(replayWriteEvents([oversizedPayload])).toEqual([{ type: 'write', data: oversizedPayload }]); + expect(new TextEncoder().encode(oversizedPayload).byteLength).toBeLessThanOrEqual(TERMINAL_BUFFER_CAP); + }); + + test('would fail if authoritative replacement replay reset twice or re-streamed replacement history chunk-by-chunk', async () => { + const initialChunks: TerminalChunk[] = [ + { id: 1, data: 'initial-live\n', replayData: 'initial-replay\n', byteLength: 13 }, + ]; + const appendedChunks: TerminalChunk[] = [ + ...initialChunks, + { id: 2, data: 'append-live\n', replayData: 'append-replay\n', byteLength: 12 }, + ]; + const replacementChunks: TerminalChunk[] = [ + { id: 3, data: 'history-live-1\n', replayData: 'history-replay-1\n', byteLength: 15 }, + { id: 4, data: 'history-live-2\n', replayData: 'history-replay-2\n', byteLength: 15 }, + ]; + const replacementReplayPayload = 'history-replay-1\nhistory-replay-2\n'; + + await renderViewport(root, initialChunks); + await flushGhosttyLoad(); + terminalEvents.length = 0; + + await renderViewport(root, appendedChunks); + expect(terminalEvents).toEqual([{ type: 'write', data: 'append-live\n' }]); + + terminalEvents.length = 0; + await renderViewport(root, replacementChunks); + expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(1); + expect(replayWriteEvents([replacementReplayPayload])).toEqual([{ type: 'write', data: replacementReplayPayload }]); + expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-replay-1\n')).toBe(false); + expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-replay-2\n')).toBe(false); + expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-live-1\n')).toBe(false); + expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-live-2\n')).toBe(false); + }); + + test('would fail if a live append after replacement replay duplicated history or lost the new chunk ordering', async () => { + const initialChunks: TerminalChunk[] = [ + { id: 1, data: 'initial-live\n', replayData: 'initial-replay\n', byteLength: 13 }, + ]; + const replacementChunks: TerminalChunk[] = [ + { id: 3, data: 'history-live-1\n', replayData: 'history-replay-1\n', byteLength: 15 }, + { id: 4, data: 'history-live-2\n', replayData: 'history-replay-2\n', byteLength: 15 }, + ]; + const resumedChunks: TerminalChunk[] = [ + ...replacementChunks, + { id: 5, data: 'tail-live\n', replayData: 'tail-replay\n', byteLength: 10 }, + ]; + const replacementReplayPayload = 'history-replay-1\nhistory-replay-2\n'; + + await renderViewport(root, initialChunks); + await flushGhosttyLoad(); + + terminalEvents.length = 0; + await renderViewport(root, replacementChunks); + await renderViewport(root, resumedChunks); + + expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(1); + expect(replayWriteEvents([replacementReplayPayload, 'tail-live\n'])).toEqual([ + { type: 'write', data: replacementReplayPayload }, + { type: 'write', data: 'tail-live\n' }, + ]); + expect(terminalEvents.filter((event) => event.type === 'write' && event.data === replacementReplayPayload)).toHaveLength(1); + expect(terminalEvents.filter((event) => event.type === 'write' && event.data === 'tail-live\n')).toHaveLength(1); + }); +}); diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index a047677d..f5342a3f 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -17,6 +17,8 @@ import { } from '@/lib/terminalTouchSelection'; import type { TerminalChunk } from '@/stores/useTerminalStore'; +import { selectTerminalChunkReplay } from './terminalChunkReplay'; + // ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView // stays eagerly importable for the bottom dock without pulling the emulator // into the startup graph before a terminal is actually mounted. @@ -328,29 +330,12 @@ const TerminalViewport = React.forwardRef(({ React.useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; - if (chunks.length === 0) { - if (lastChunkRef.current !== null) recreateRenderer(); - return; - } - const previous = lastChunkRef.current; - // Chunk ids are monotonic and the store appends, so the already-written chunk - // is normally the last one. Scanning from the end keeps this O(1) per chunk - // instead of O(chunks) on every streamed write. - let previousIndex = -1; - if (previous !== null) { - for (let index = chunks.length - 1; index >= 0; index -= 1) { - const id = chunks[index].id; - if (id === previous) { previousIndex = index; break; } - if (id < previous) break; - } - if (previousIndex < 0) { - recreateRenderer(); - return; - } - } - const isReplay = previousIndex < 0; - const pending = previousIndex >= 0 ? chunks.slice(previousIndex + 1) : chunks; - writeQueueRef.current += pending.map((chunk) => isReplay ? (chunk.replayData ?? chunk.data) : chunk.data).join(''); + const { reset, replay, pending } = selectTerminalChunkReplay(chunks, lastChunkRef.current); + if (reset) recreateRenderer(); + if (pending.length === 0) return; + writeQueueRef.current += pending + .map((chunk) => replay ? (chunk.replayData ?? chunk.data) : chunk.data) + .join(''); lastChunkRef.current = chunks.at(-1)?.id ?? null; flush(); }, [chunks, flush, ready, recreateRenderer]); diff --git a/packages/ui/src/components/terminal/terminalChunkReplay.test.ts b/packages/ui/src/components/terminal/terminalChunkReplay.test.ts new file mode 100644 index 00000000..3efc94c4 --- /dev/null +++ b/packages/ui/src/components/terminal/terminalChunkReplay.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; + +import type { TerminalChunk } from '@/stores/useTerminalStore'; + +import { selectTerminalChunkReplay } from './terminalChunkReplay'; + +const chunk = (id: number, data = `${id}`): TerminalChunk => ({ + id, + data, + byteLength: data.length, +}); + +describe('selectTerminalChunkReplay', () => { + const firstMountChunks = [chunk(1, 'one'), chunk(2, 'two')]; + const incrementalChunks = [chunk(4, 'four'), chunk(5, 'five'), chunk(6, 'six')]; + const replacementChunks = [chunk(8, 'eight'), chunk(9, 'nine')]; + + const cases: Array<{ + name: string; + chunks: TerminalChunk[]; + lastChunkId: number | null; + expected: { reset: boolean; replay: boolean; pending: TerminalChunk[] }; + }> = [ + { + name: 'first mount replays the full current buffer without resetting the renderer', + chunks: firstMountChunks, + lastChunkId: null, + expected: { reset: false, replay: true, pending: firstMountChunks }, + }, + { + name: 'known tail appends only newer chunks incrementally', + chunks: incrementalChunks, + lastChunkId: 5, + expected: { reset: false, replay: false, pending: [incrementalChunks[2]!] }, + }, + { + name: 'missing prior id replaces the whole current buffer', + chunks: replacementChunks, + lastChunkId: 7, + expected: { reset: true, replay: true, pending: replacementChunks }, + }, + { + name: 'a prior id newer than the current tail replaces the whole current buffer', + chunks: replacementChunks, + lastChunkId: 10, + expected: { reset: true, replay: true, pending: replacementChunks }, + }, + { + name: 'an empty current buffer resets only when prior content existed', + chunks: [], + lastChunkId: 12, + expected: { reset: true, replay: false, pending: [] }, + }, + { + name: 'an empty current buffer without prior content is a no-op', + chunks: [], + lastChunkId: null, + expected: { reset: false, replay: false, pending: [] }, + }, + ]; + + for (const { name, chunks, lastChunkId, expected } of cases) { + test(name, () => { + expect(selectTerminalChunkReplay(chunks, lastChunkId)).toEqual(expected); + }); + } +}); diff --git a/packages/ui/src/components/terminal/terminalChunkReplay.ts b/packages/ui/src/components/terminal/terminalChunkReplay.ts new file mode 100644 index 00000000..1890717b --- /dev/null +++ b/packages/ui/src/components/terminal/terminalChunkReplay.ts @@ -0,0 +1,52 @@ +import type { TerminalChunk } from '@/stores/useTerminalStore'; + +export interface TerminalChunkReplaySelection { + reset: boolean; + replay: boolean; + pending: TerminalChunk[]; +} + +export function selectTerminalChunkReplay( + chunks: TerminalChunk[], + lastChunkId: number | null, +): TerminalChunkReplaySelection { + if (chunks.length === 0) { + return { + reset: lastChunkId !== null, + replay: false, + pending: [], + }; + } + + if (lastChunkId === null) { + return { + reset: false, + replay: true, + pending: chunks, + }; + } + + let previousIndex = -1; + for (let index = chunks.length - 1; index >= 0; index -= 1) { + const id = chunks[index].id; + if (id === lastChunkId) { + previousIndex = index; + break; + } + if (id < lastChunkId) break; + } + + if (previousIndex < 0) { + return { + reset: true, + replay: true, + pending: chunks, + }; + } + + return { + reset: false, + replay: false, + pending: chunks.slice(previousIndex + 1), + }; +} diff --git a/packages/ui/src/components/views/TerminalView.test.tsx b/packages/ui/src/components/views/TerminalView.test.tsx new file mode 100644 index 00000000..3153b9ac --- /dev/null +++ b/packages/ui/src/components/views/TerminalView.test.tsx @@ -0,0 +1,405 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Window } from 'happy-dom'; + +import type { TerminalHandlers } from '@/lib/api/types'; +import { useTerminalStore } from '@/stores/useTerminalStore'; + +let effectiveDirectory = '/repo'; +const openContextPreviewCalls: Array<[string, string]> = []; +const createSessionCalls: Array<{ cwd: string }> = []; +const connectCalls: string[] = []; +const ensureDirectoryCalls: string[] = []; +const openContextPreview = (directory: string, url: string) => { + openContextPreviewCalls.push([directory, url]); +}; +const createSession = async ({ cwd }: { cwd: string }) => { + createSessionCalls.push({ cwd }); + return { sessionId: 'unused', cols: 80, rows: 24, status: 'running' as const }; +}; +let connectBehavior: (sessionId: string, handlers: TerminalHandlers) => { close: () => void } = () => ({ close: () => undefined }); +const terminalRuntime = { + createSession, + sendInput: async () => undefined, + resize: async () => undefined, + close: async () => undefined, + updateAppearance: async () => undefined, + connect: (sessionId: string, handlers: TerminalHandlers) => { + connectCalls.push(sessionId); + return connectBehavior(sessionId, handlers); + }, +}; +const runtimeApis = { + runtime: { platform: 'web' as const }, + terminal: terminalRuntime, +}; +const i18n = { t: (key: string) => key }; + +const sessionUiState = { + currentSessionId: 'session-1', + newSessionDraft: null, +}; + +const useSessionUIStoreMock = (selector: (state: typeof sessionUiState) => T): T => selector(sessionUiState); + +const uiState = { + terminalFontSize: 14, + terminalShell: 'zsh', + terminalLoginShells: ['zsh'], + showTerminalQuickKeysOnDesktop: false, + openContextPreview, +}; + +const useUiStoreMock = Object.assign( + (selector: (state: typeof uiState) => T): T => selector(uiState), + { getState: () => uiState }, +); + +mock.module('@/sync/session-ui-store', () => ({ useSessionUIStore: useSessionUIStoreMock })); +mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => effectiveDirectory })); +mock.module('@/hooks/useRuntimeAPIs', () => ({ + useRuntimeAPIs: () => runtimeApis, +})); +mock.module('@/contexts/useThemeSystem', () => ({ + useThemeSystem: () => ({ + currentTheme: { + metadata: { variant: 'dark' }, + colors: { + surface: { + background: '#000', + muted: '#111', + elevatedForeground: '#fff', + }, + syntax: { + base: { foreground: '#fff' }, + function: '#7dd3fc', + keyword: '#c084fc', + type: '#67e8f9', + comment: '#6b7280', + }, + interactive: { + cursor: '#fff', + selection: '#334155', + selectionForeground: '#fff', + }, + status: { + error: '#f87171', + success: '#4ade80', + warning: '#fbbf24', + }, + }, + }, + }), +})); +mock.module('@/hooks/useFontPreferences', () => ({ useFontPreferences: () => ({ monoFont: 'geist-mono' }) })); +mock.module('@/lib/device', () => ({ useDeviceInfo: () => ({ isMobile: false, isTablet: false, hasTouchOnlyPointer: false }) })); +mock.module('@/stores/useUIStore', () => ({ useUIStore: useUiStoreMock })); +mock.module('@/stores/useInlineCommentDraftStore', () => ({ useInlineCommentDraftStore: () => ({ addDraft: () => undefined }) })); +mock.module('@/components/terminal/TerminalViewport', () => ({ + TerminalViewport: React.forwardRef(function TerminalViewportMock( + { sessionKey, chunks, isVisible }: { sessionKey: string; chunks: unknown[]; isVisible: boolean }, + ref: React.ForwardedRef<{ focus: () => void; fit: () => void; getSelection: () => null }>, + ) { + React.useImperativeHandle(ref, () => ({ + focus: () => undefined, + fit: () => undefined, + getSelection: () => null, + }), []); + + return React.createElement('div', { + 'data-terminal-viewport': 'true', + 'data-session-key': sessionKey, + 'data-visible': String(isVisible), + 'data-chunk-count': String(chunks.length), + }); + }), +})); +mock.module('@/components/icon/Icon', () => ({ + Icon: ({ name, className }: { name: string; className?: string }) => React.createElement('span', { 'data-icon': name, className }), +})); +mock.module('@/components/ui/sortable-tabs-strip', () => ({ + SortableTabsStrip: ({ items }: { items: Array<{ id: string; label: string; icon?: React.ReactNode }> }) => React.createElement( + 'div', + { 'data-tabs-strip': 'terminal' }, + items.map((item) => React.createElement( + 'div', + { key: item.id, 'data-tab-id': item.id }, + item.icon, + React.createElement('span', { 'data-tab-label': item.id }, item.label), + )), + ), +})); +mock.module('@/lib/i18n', () => ({ useI18n: () => i18n })); + +const { TerminalView } = await import('./TerminalView'); + +const ensureDirectorySpy = (directory: string) => { + ensureDirectoryCalls.push(directory); + useTerminalStore.setState((state) => { + if (state.sessions.get(directory)) return state; + + const tab = { + id: `spy-tab-${directory}`, + terminalSessionId: null, + lifecycle: 'idle' as const, + purpose: { type: 'terminal' as const }, + label: 'Terminal', + iconKey: null, + isConnecting: false, + createdAt: Date.now(), + previewUrl: null, + previewAutoOpened: false, + previewUrlLocked: false, + }; + + const sessions = new Map(state.sessions); + sessions.set(directory, { tabs: [tab], activeTabId: tab.id }); + return { sessions }; + }); +}; + +const flushEffects = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const bufferEntryKey = (directory: string, tabId: string) => `${directory}\u0000${tabId}`; +const readBufferContent = (directory: string, tabId: string) => useTerminalStore.getState().getBuffer(directory, tabId).chunks.map((chunk) => chunk.data).join(''); + +describe('TerminalView project action tab indicator', () => { + let windowInstance: Window; + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + effectiveDirectory = '/repo'; + openContextPreviewCalls.length = 0; + createSessionCalls.length = 0; + connectCalls.length = 0; + ensureDirectoryCalls.length = 0; + connectBehavior = () => ({ close: () => undefined }); + windowInstance = new Window({ url: 'http://localhost/' }); + Object.assign(globalThis, { + window: windowInstance, + document: windowInstance.document, + navigator: windowInstance.navigator, + HTMLElement: windowInstance.HTMLElement, + Element: windowInstance.Element, + Node: windowInstance.Node, + Event: windowInstance.Event, + KeyboardEvent: windowInstance.KeyboardEvent, + MouseEvent: windowInstance.MouseEvent, + ResizeObserver: class { + observe() {} + disconnect() {} + }, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + + useTerminalStore.getState().clearAll(); + useTerminalStore.setState({ ensureDirectory: ensureDirectorySpy }); + useTerminalStore.getState().ensureDirectory('/repo'); + + const interactiveTabId = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!.id; + useTerminalStore.getState().setTabLabel('/repo', interactiveTabId, 'Interactive'); + + const runningActionTabId = useTerminalStore.getState().createTab('/repo'); + useTerminalStore.getState().setTabLabel('/repo', runningActionTabId, 'Build'); + useTerminalStore.getState().setTabIconKey('/repo', runningActionTabId, 'build'); + useTerminalStore.getState().setTabPurpose('/repo', runningActionTabId, { type: 'project-action', actionId: 'build', executionId: 'exec-running' }); + useTerminalStore.getState().setTabLifecycle('/repo', runningActionTabId, 'running'); + + const exitedActionTabId = useTerminalStore.getState().createTab('/repo'); + useTerminalStore.getState().setTabLabel('/repo', exitedActionTabId, 'Deploy'); + useTerminalStore.getState().setTabIconKey('/repo', exitedActionTabId, 'play'); + useTerminalStore.getState().setTabPurpose('/repo', exitedActionTabId, { type: 'project-action', actionId: 'deploy', executionId: 'exec-exited' }); + useTerminalStore.getState().setTabLifecycle('/repo', exitedActionTabId, 'exited'); + ensureDirectoryCalls.length = 0; + }); + + afterEach(async () => { + await act(async () => root.unmount()); + useTerminalStore.getState().clearAll(); + }); + + test('shows a spinner only for active project-action tabs and keeps terminal or action icons elsewhere', async () => { + await act(async () => { + root.render(React.createElement(TerminalView, { visible: false })); + }); + + const tabs = Array.from(host.querySelectorAll('[data-tab-id]')); + expect(tabs).toHaveLength(3); + + const interactiveTab = tabs.find((tab) => tab.querySelector('[data-tab-label]')?.textContent === 'Interactive'); + const runningActionTab = tabs.find((tab) => tab.querySelector('[data-tab-label]')?.textContent === 'Build'); + const exitedActionTab = tabs.find((tab) => tab.querySelector('[data-tab-label]')?.textContent === 'Deploy'); + + expect(interactiveTab?.querySelector('[data-icon]')?.getAttribute('data-icon')).toBe('terminal'); + expect(runningActionTab?.querySelector('[data-icon]')?.getAttribute('data-icon')).toBe('loader-4'); + expect(runningActionTab?.querySelector('[data-icon]')?.className).toContain('animate-spin'); + expect(runningActionTab?.querySelector('[data-icon]')?.className).toContain('motion-reduce:animate-none'); + expect(runningActionTab?.querySelector('[data-icon]')?.className).toContain('text-muted-foreground'); + expect(exitedActionTab?.querySelector('[data-icon]')?.getAttribute('data-icon')).toBe('play'); + expect(host.querySelectorAll('[data-icon="loader-4"]').length).toBe(1); + }); + + test('uses the explicit terminal directory for terminal tabs and session creation while preview ownership stays on the host directory', async () => { + effectiveDirectory = '/repo-worktree'; + useTerminalStore.getState().ensureDirectory('/repo-worktree'); + const worktreeTabId = useTerminalStore.getState().getDirectoryState('/repo-worktree')!.tabs[0]!.id; + useTerminalStore.getState().setTabLabel('/repo-worktree', worktreeTabId, 'Worktree Terminal'); + + const repoTabId = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!.id; + useTerminalStore.getState().setTabLabel('/repo', repoTabId, 'Repo Terminal'); + useTerminalStore.getState().setTabPreviewUrl('/repo', repoTabId, 'https://preview.example.test'); + + await act(async () => { + root.render(React.createElement(TerminalView, { visible: true, directory: '/repo' })); + }); + + const tabLabels = Array.from(host.querySelectorAll('[data-tab-label]')).map((node) => node.textContent); + expect(tabLabels).toContain('Repo Terminal'); + expect(tabLabels).not.toContain('Worktree Terminal'); + expect(createSessionCalls.length).toBe(1); + expect(createSessionCalls[0]?.cwd).toBe('/repo'); + + const previewButton = host.querySelector('[title="terminalView.preview.openTitle"]'); + expect(previewButton).not.toBeNull(); + previewButton?.click(); + expect(openContextPreviewCalls).toEqual([['/repo-worktree', 'https://preview.example.test']]); + }); + + test('keeps the existing context-directory behavior when no explicit terminal directory is provided', async () => { + effectiveDirectory = '/repo-worktree'; + useTerminalStore.getState().ensureDirectory('/repo-worktree'); + const worktreeTabId = useTerminalStore.getState().getDirectoryState('/repo-worktree')!.tabs[0]!.id; + useTerminalStore.getState().setTabLabel('/repo-worktree', worktreeTabId, 'Worktree Terminal'); + + await act(async () => { + root.render(React.createElement(TerminalView, { visible: true })); + }); + + const tabLabels = Array.from(host.querySelectorAll('[data-tab-label]')).map((node) => node.textContent); + expect(tabLabels).toContain('Worktree Terminal'); + expect(createSessionCalls.length).toBe(1); + expect(createSessionCalls[0]?.cwd).toBe('/repo-worktree'); + }); + + test('treats an explicit terminal target with no terminal state as an inert reveal', async () => { + effectiveDirectory = '/repo-worktree'; + useTerminalStore.getState().ensureDirectory('/repo-worktree'); + + await act(async () => { + root.render(React.createElement(TerminalView, { visible: true, directory: '/missing-repo' })); + }); + + expect(ensureDirectoryCalls).not.toContain('/missing-repo'); + expect(createSessionCalls.length).toBe(0); + expect(host.querySelector('[data-tabs-strip="terminal"]')).toBeNull(); + expect(host.querySelector('[data-terminal-viewport="true"]')?.getAttribute('data-chunk-count')).toBe('0'); + }); + + test('includes the terminal directory in the viewport identity key', async () => { + effectiveDirectory = '/repo-worktree'; + useTerminalStore.getState().ensureDirectory('/repo-worktree'); + + useTerminalStore.setState((state) => { + const repoTab = state.sessions.get('/repo')!.tabs[0]!; + const sessions = new Map(state.sessions); + sessions.set('/repo-worktree', { + tabs: [{ ...repoTab, label: 'Mirrored Terminal' }], + activeTabId: repoTab.id, + }); + return { sessions }; + }); + + await act(async () => { + root.render(React.createElement(TerminalView, { visible: true })); + }); + const contextKey = host.querySelector('[data-terminal-viewport="true"]')!.getAttribute('data-session-key'); + + await act(async () => { + root.render(React.createElement(TerminalView, { visible: true, directory: '/repo' })); + }); + const targetKey = host.querySelector('[data-terminal-viewport="true"]')!.getAttribute('data-session-key'); + + expect(contextKey).not.toBe(targetKey); + expect(contextKey).toContain('/repo-worktree'); + expect(targetKey).toContain('/repo'); + }); + + test('would fail if revisit attach skipped the active running project-action snapshot restore', async () => { + const state = useTerminalStore.getState().getDirectoryState('/repo'); + const actionTab = state?.tabs.find((tab) => tab.label === 'Build'); + expect(actionTab).toBeDefined(); + if (!actionTab) throw new Error('action tab missing'); + + useTerminalStore.getState().setTabSessionId('/repo', actionTab.id, 'srv-build', { expectedExecutionId: 'exec-running' }); + useTerminalStore.getState().setActiveTab('/repo', actionTab.id); + + const snapshotData = 'snapshot history\nfinal line\n'; + let replaceCount = 0; + const unsubscribe = useTerminalStore.subscribe((nextState, previousState) => { + const next = nextState.buffers.get(bufferEntryKey('/repo', actionTab.id)); + const previous = previousState.buffers.get(bufferEntryKey('/repo', actionTab.id)); + const nextContent = next?.chunks.map((chunk) => chunk.data).join('') ?? ''; + const previousContent = previous?.chunks.map((chunk) => chunk.data).join('') ?? ''; + if (nextContent === snapshotData && previousContent !== snapshotData && next?.lastSequence === 7) { + replaceCount += 1; + } + }); + connectBehavior = (_sessionId, handlers) => { + void Promise.resolve().then(() => { + handlers.onEvent({ type: 'snapshot', data: snapshotData, sequence: 7, status: 'running' }); + }); + return { close: () => undefined }; + }; + + await act(async () => { + root.render(React.createElement(TerminalView, { visible: true })); + }); + await flushEffects(); + unsubscribe(); + + expect(connectCalls).toEqual(['srv-build']); + expect(createSessionCalls.length).toBe(0); + expect(readBufferContent('/repo', actionTab.id)).toBe(snapshotData); + expect(useTerminalStore.getState().getBuffer('/repo', actionTab.id).lastSequence).toBe(7); + expect(replaceCount).toBe(1); + }); + + test('would fail if retained parent action targets rendered worktree tabs or attached the wrong session', async () => { + effectiveDirectory = '/repo-worktree'; + useTerminalStore.getState().ensureDirectory('/repo-worktree'); + const worktreeTabId = useTerminalStore.getState().getDirectoryState('/repo-worktree')!.tabs[0]!.id; + useTerminalStore.getState().setTabLabel('/repo-worktree', worktreeTabId, 'Worktree Terminal'); + + const repoState = useTerminalStore.getState().getDirectoryState('/repo'); + const repoActionTab = repoState?.tabs.find((tab) => tab.label === 'Build'); + expect(repoActionTab).toBeDefined(); + if (!repoActionTab) throw new Error('repo action tab missing'); + + useTerminalStore.getState().setTabLabel('/repo', repoActionTab.id, 'Repo Build'); + useTerminalStore.getState().setTabSessionId('/repo', repoActionTab.id, 'srv-parent-build', { expectedExecutionId: 'exec-running' }); + useTerminalStore.getState().setActiveTab('/repo', repoActionTab.id); + + await act(async () => { + root.render(React.createElement(TerminalView, { visible: true, directory: '/repo' })); + }); + await flushEffects(); + + const tabLabels = Array.from(host.querySelectorAll('[data-tab-label]')).map((node) => node.textContent); + expect(tabLabels).toContain('Repo Build'); + expect(tabLabels).toContain('Interactive'); + expect(tabLabels).not.toContain('Worktree Terminal'); + expect(connectCalls).toEqual(['srv-parent-build']); + expect(createSessionCalls.length).toBe(0); + }); +}); diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 5e25ca0b..c09bbe42 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { EMPTY_TERMINAL_BUFFER, useTerminalStore } from '@/stores/useTerminalStore'; +import { ACTIVE_PROJECT_ACTION_LIFECYCLES, EMPTY_TERMINAL_BUFFER, useTerminalStore } from '@/stores/useTerminalStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { type TerminalStreamEvent } from '@/lib/api/types'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -14,22 +14,29 @@ import { useUIStore } from '@/stores/useUIStore'; import { Button } from '@/components/ui/button'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { Icon } from "@/components/icon/Icon"; +import type { IconName } from '@/components/icon/icons'; import { useDeviceInfo } from '@/lib/device'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview'; import { useI18n } from '@/lib/i18n'; -import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions'; +import { PROJECT_ACTION_ICONS } from '@/lib/projectActions'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput'; import { formatShortcutForDisplay } from '@/lib/shortcuts'; +import { reconcileTerminalSessionAuthority } from '@/lib/projectActionTerminal'; type TerminalViewProps = { visible?: boolean; + directory?: string | null; }; const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const; +const resolveTabIconName = (iconKey: string | null): IconName => { + const matchedIcon = PROJECT_ACTION_ICONS.find((entry) => entry.key === iconKey); + return matchedIcon?.Icon ?? 'terminal'; +}; -export const TerminalView: React.FC = ({ visible }) => { +export const TerminalView: React.FC = ({ visible, directory }) => { const { t } = useI18n(); const { terminal, runtime } = useRuntimeAPIs(); const { currentTheme } = useThemeSystem(); @@ -51,15 +58,19 @@ export const TerminalView: React.FC = ({ visible }) => { const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true; - const effectiveDirectory = useEffectiveDirectory() ?? null; - const directoryTerminalState = useTerminalStore((s) => effectiveDirectory ? s.sessions.get(effectiveDirectory) : undefined); + const contextDirectory = useEffectiveDirectory() ?? null; + const targetDirectory = directory ?? null; + const terminalDirectory = targetDirectory || contextDirectory; + const hasExplicitTerminalTarget = targetDirectory !== null; + const directoryTerminalState = useTerminalStore((s) => terminalDirectory ? s.sessions.get(terminalDirectory) : undefined); const terminalHydrated = useTerminalStore((s) => s.hasHydrated); const ensureDirectory = useTerminalStore((s) => s.ensureDirectory); const createTab = useTerminalStore((s) => s.createTab); const setActiveTab = useTerminalStore((s) => s.setActiveTab); const closeTab = useTerminalStore((s) => s.closeTab); const setTabSessionId = useTerminalStore((s) => s.setTabSessionId); - const adoptServerSessions = useTerminalStore((s) => s.adoptServerSessions); + const reconcileServerSessions = useTerminalStore((s) => s.reconcileServerSessions); + const captureStartedActionMutationRevisions = useTerminalStore((s) => s.captureStartedActionMutationRevisions); const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle); const setConnecting = useTerminalStore((s) => s.setConnecting); const appendToBuffer = useTerminalStore((s) => s.appendToBuffer); @@ -89,8 +100,20 @@ export const TerminalView: React.FC = ({ visible }) => { const terminalTabItems = React.useMemo(() => { return (directoryTerminalState?.tabs ?? []).map((tab) => ({ icon: (() => { - const tabIconName = tab.iconKey ? PROJECT_ACTION_ICON_MAP[tab.iconKey as ProjectActionIconKey] ?? 'terminal' : 'terminal'; - return ; + const showProjectActionSpinner = tab.purpose.type === 'project-action' + && ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle); + const tabIconName = showProjectActionSpinner + ? 'loader-4' + : resolveTabIconName(tab.iconKey); + return ( + + ); })(), id: tab.id, label: tab.label, @@ -101,9 +124,10 @@ export const TerminalView: React.FC = ({ visible }) => { const terminalSessionId = activeTab?.terminalSessionId ?? null; const terminalLifecycle = activeTab?.lifecycle ?? 'idle'; + const isActionTab = activeTab?.purpose.type === 'project-action'; // Scrollback is a leaf subscription: streaming output must not rerender the tab strip. const bufferChunks = useTerminalStore((s) => ( - effectiveDirectory && activeTabId ? s.getBuffer(effectiveDirectory, activeTabId).chunks : EMPTY_TERMINAL_BUFFER.chunks + terminalDirectory && activeTabId ? s.getBuffer(terminalDirectory, activeTabId).chunks : EMPTY_TERMINAL_BUFFER.chunks )); const isConnecting = activeTab?.isConnecting ?? false; const previewUrl = activeTab?.previewUrl ?? null; @@ -118,7 +142,7 @@ export const TerminalView: React.FC = ({ visible }) => { const activeTerminalIdRef = React.useRef(null); const activeTabIdRef = React.useRef(activeTabId); const terminalIdRef = React.useRef(terminalSessionId); - const directoryRef = React.useRef(effectiveDirectory); + const directoryRef = React.useRef(terminalDirectory); const terminalControllerRef = React.useRef(null); const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); const pendingTerminalCreatesRef = React.useRef(new Set()); @@ -173,52 +197,32 @@ export const TerminalView: React.FC = ({ visible }) => { }, [activeTabId, resetTerminalPreviewScan]); React.useEffect(() => { - directoryRef.current = effectiveDirectory; - }, [effectiveDirectory]); + directoryRef.current = terminalDirectory; + }, [terminalDirectory]); // The tab list is a per-client projection, so ask the server what actually // exists for this directory and adopt sessions no local tab references // (another device, a fresh browser tab, or a reload with cleared storage). // A failed listing changes nothing: adoption is additive only. React.useEffect(() => { - if (!terminalHydrated || !effectiveDirectory || !terminal.listSessions) { + if (!terminalHydrated || !terminalDirectory || !terminal.listSessions) { return; } let cancelled = false; - const directory = effectiveDirectory; - void terminal.listSessions(directory) - .then((serverSessions) => { - if (cancelled || directoryRef.current !== directory) return; - adoptServerSessions(directory, serverSessions); - }) - .catch(() => { /* keep local tabs; the next mount or directory switch retries */ }); + const directory = terminalDirectory; + void reconcileTerminalSessionAuthority(terminal, directory, { + captureStartedActionMutationRevisions, + }) + .then((result) => { + if (cancelled || directoryRef.current !== directory || !result) return; + reconcileServerSessions(directory, result.sessions, { + startedActionMutationRevisions: result.startedActionMutationRevisions, + }); + }); return () => { cancelled = true; }; - }, [terminalHydrated, effectiveDirectory, terminal, adoptServerSessions]); - - // The server reaps terminals with no attached socket after an idle timeout, - // but only the active tab holds an attachment. While this client is open, - // periodically mark every session its tabs reference as active so - // background tabs (and other directories' terminals) are not reaped. - React.useEffect(() => { - if (!terminal.touchSessions) { - return; - } - const touch = () => { - if (typeof navigator !== 'undefined' && !navigator.onLine) return; - const ids: string[] = []; - for (const dirState of useTerminalStore.getState().sessions.values()) { - for (const tab of dirState.tabs) { - if (tab.terminalSessionId) ids.push(tab.terminalSessionId); - } - } - if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {}); - }; - touch(); - const interval = setInterval(touch, 10 * 60 * 1000); - return () => clearInterval(interval); - }, [terminal]); + }, [captureStartedActionMutationRevisions, terminalHydrated, terminalDirectory, terminal, reconcileServerSessions]); React.useEffect(() => { if (!showQuickKeys && activeModifier !== null) { @@ -345,7 +349,7 @@ export const TerminalView: React.FC = ({ visible }) => { const currentTab = useTerminalStore.getState() .getDirectoryState(directory) ?.tabs.find((t) => t.id === tabId); - const isActionTab = Boolean(currentTab?.label?.startsWith('Action:')); + const isActionTab = currentTab?.purpose.type === 'project-action'; appendToBuffer( directory, tabId, @@ -384,7 +388,7 @@ export const TerminalView: React.FC = ({ visible }) => { setIsReconnectPending(false); if (error.code === 'SESSION_NOT_FOUND') { const currentTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((tab) => tab.id === tabId); - if (!currentTab?.label?.startsWith('Action:')) { + if (currentTab?.purpose.type !== 'project-action') { setConnectionError(null); setIsFatalError(false); setConnecting(directory, tabId, false); @@ -432,7 +436,7 @@ export const TerminalView: React.FC = ({ visible }) => { return; } - if (!effectiveDirectory) { + if (!terminalDirectory) { setConnectionError( hasActiveContext ? t('terminalView.empty.noWorkingDirectory') @@ -443,11 +447,14 @@ export const TerminalView: React.FC = ({ visible }) => { } const ensureSession = async () => { - const directory = effectiveDirectory; + const directory = terminalDirectory; if (!directoryRef.current || directoryRef.current !== directory) return; const existingState = useTerminalStore.getState().getDirectoryState(directory); if (!existingState) { + if (hasExplicitTerminalTarget) { + return; + } ensureDirectory(directory); return; } @@ -467,17 +474,14 @@ export const TerminalView: React.FC = ({ visible }) => { const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0]; const terminalId = tab?.terminalSessionId ?? null; const terminalLifecycle = tab?.lifecycle ?? 'idle'; - const isActionTab = Boolean(tab?.label?.startsWith('Action:')); - const buffer = useTerminalStore.getState().getBuffer(directory, tabId); - const hasBufferedOutput = buffer.byteLength > 0 || buffer.chunks.length > 0; - + const tabIsActionTab = tab?.purpose.type === 'project-action'; if (!terminalId) { if (terminalLifecycle === 'exited') { setConnecting(directory, tabId, false); return; } - if (isActionTab && hasBufferedOutput) { + if (tabIsActionTab) { setConnecting(directory, tabId, false); return; } @@ -574,7 +578,8 @@ export const TerminalView: React.FC = ({ visible }) => { }; }, [ hasActiveContext, - effectiveDirectory, + terminalDirectory, + hasExplicitTerminalTarget, terminalSessionId, terminalLifecycle, activeTabId, @@ -613,10 +618,11 @@ export const TerminalView: React.FC = ({ visible }) => { }, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible, useTouchTerminalInput]); const handleRestart = React.useCallback(async () => { - if (!effectiveDirectory) return; + if (!terminalDirectory) return; if (isRestarting) return; + if (isActionTab) return; - const state = useTerminalStore.getState().getDirectoryState(effectiveDirectory); + const state = useTerminalStore.getState().getDirectoryState(terminalDirectory); const tabId = enableTabs ? (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null) : (state?.tabs[0]?.id ?? null); @@ -634,19 +640,19 @@ export const TerminalView: React.FC = ({ visible }) => { try { const size = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE; - const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current }); - const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId); + const restarted = await terminal.restartSession(originalSessionId, { cwd: terminalDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current }); + const owningTab = useTerminalStore.getState().getDirectoryState(terminalDirectory)?.tabs.find((tab) => tab.id === tabId); if (owningTab?.terminalSessionId !== originalSessionId) return; - setTabSessionId(effectiveDirectory, tabId, restarted.sessionId); - setTabLifecycle(effectiveDirectory, tabId, 'running'); - if (directoryRef.current !== effectiveDirectory || activeTabIdRef.current !== tabId) return; + setTabSessionId(terminalDirectory, tabId, restarted.sessionId); + setTabLifecycle(terminalDirectory, tabId, 'running'); + if (directoryRef.current !== terminalDirectory || activeTabIdRef.current !== tabId) return; terminalIdRef.current = restarted.sessionId; - startStream(effectiveDirectory, tabId, restarted.sessionId); + startStream(terminalDirectory, tabId, restarted.sessionId); } catch (error) { - const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId); + const owningTab = useTerminalStore.getState().getDirectoryState(terminalDirectory)?.tabs.find((tab) => tab.id === tabId); if ( owningTab?.terminalSessionId !== originalSessionId - || directoryRef.current !== effectiveDirectory + || directoryRef.current !== terminalDirectory || activeTabIdRef.current !== tabId ) return; setConnectionError( @@ -655,11 +661,11 @@ export const TerminalView: React.FC = ({ visible }) => { setIsFatalError(false); setIsReconnectPending(false); terminalIdRef.current = originalSessionId; - startStream(effectiveDirectory, tabId, originalSessionId); + startStream(terminalDirectory, tabId, originalSessionId); } finally { setIsRestarting(false); } - }, [activeTabId, disconnectStream, effectiveDirectory, enableTabs, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]); + }, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]); const handleHardRestart = React.useCallback(async () => { // Keep semantics: “close tab -> new clean tab”. @@ -667,20 +673,20 @@ export const TerminalView: React.FC = ({ visible }) => { }, [handleRestart]); const handleCreateTab = React.useCallback(() => { - if (!effectiveDirectory) return; - const tabId = createTab(effectiveDirectory); - setActiveTab(effectiveDirectory, tabId); + if (!terminalDirectory) return; + const tabId = createTab(terminalDirectory); + setActiveTab(terminalDirectory, tabId); setConnectionError(null); setIsFatalError(false); setIsReconnectPending(false); disconnectStream(); - }, [createTab, disconnectStream, effectiveDirectory, setActiveTab]); + }, [createTab, disconnectStream, terminalDirectory, setActiveTab]); const handleAttachSelection = React.useCallback(() => { const selection = terminalControllerRef.current?.getSelection(); const sessionKey = currentSessionId ?? (newSessionDraft?.open ? 'draft' : null); - if (!selection || !sessionKey || !activeTab || !effectiveDirectory) return; - addContextDraft({ directory: effectiveDirectory, sessionKey }, { + if (!selection || !sessionKey || !activeTab || !contextDirectory) return; + addContextDraft({ directory: contextDirectory, sessionKey }, { source: 'terminal', fileLabel: activeTab.label, startLine: selection.startLine, @@ -690,23 +696,23 @@ export const TerminalView: React.FC = ({ visible }) => { terminalId: activeTab.terminalSessionId ?? activeTab.id, text: '', }); - }, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]); + }, [activeTab, addContextDraft, contextDirectory, currentSessionId, newSessionDraft?.open]); const handleSelectTab = React.useCallback( (tabId: string) => { - if (!effectiveDirectory) return; - setActiveTab(effectiveDirectory, tabId); + if (!terminalDirectory) return; + setActiveTab(terminalDirectory, tabId); setConnectionError(null); setIsFatalError(false); setIsReconnectPending(false); disconnectStream(); }, - [disconnectStream, effectiveDirectory, setActiveTab] + [disconnectStream, terminalDirectory, setActiveTab] ); const handleCloseTab = React.useCallback( (tabId: string) => { - if (!effectiveDirectory) return; + if (!terminalDirectory) return; if (tabId === activeTabId) { disconnectStream(); @@ -715,13 +721,13 @@ export const TerminalView: React.FC = ({ visible }) => { setConnectionError(null); setIsFatalError(false); setIsReconnectPending(false); - const sessionId = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId)?.terminalSessionId; + const sessionId = useTerminalStore.getState().getDirectoryState(terminalDirectory)?.tabs.find((tab) => tab.id === tabId)?.terminalSessionId; void (async () => { if (sessionId) await terminal.close(sessionId); - closeTab(effectiveDirectory, tabId); + closeTab(terminalDirectory, tabId); })().catch((error) => setConnectionError(error instanceof Error ? error.message : t('terminalView.error.sessionEnded'))); }, - [activeTabId, closeTab, disconnectStream, effectiveDirectory, t, terminal] + [activeTabId, closeTab, disconnectStream, terminalDirectory, t, terminal] ); const handleViewportInput = React.useCallback( @@ -862,7 +868,7 @@ export const TerminalView: React.FC = ({ visible }) => { // here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font // atlas) a second time the moment `createSession` resolved, doubling the cost // of every terminal open. Session changes are handled by the chunk replay path. - const terminalViewportKey = `${effectiveDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`; + const terminalViewportKey = `${terminalDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`; React.useEffect(() => { if (!isTerminalVisible || useTouchTerminalInput) { @@ -914,7 +920,7 @@ export const TerminalView: React.FC = ({ visible }) => { ); } - if (!effectiveDirectory) { + if (!terminalDirectory) { return (

{t('terminalView.empty.noWorkingDirectoryForSession')}

@@ -1077,7 +1083,7 @@ export const TerminalView: React.FC = ({ visible }) => {
-