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
This commit is contained in:
Matt Visnovsky
2026-09-05 12:04:36 +03:00
committed by GitHub
parent 58dfc789a2
commit 4e0eed717d
53 changed files with 5194 additions and 608 deletions
+5
View File
@@ -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<MobileSurface | null>(null);
// Phone right drawer with the workspace tabs; the tab persists across
@@ -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 = () => {
</React.Suspense>
</div>
))}
{hasTerminalTab ? (
{terminalTab ? (
<div className={cn('absolute inset-0', activeTab?.mode === 'terminal' ? 'block' : 'hidden')}>
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} />
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} directory={terminalTab.targetDirectory} />
</div>
) : null}
{hasWalkthroughTab ? (
@@ -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);
@@ -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<string>(),
info: new Array<string>(),
success: new Array<string>(),
} 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(
<T,>(selector: (state: typeof uiState) => T): T => selector(uiState),
{ getState: () => uiState },
);
const desktopSshState = { instances: [], load: async () => undefined };
const useDesktopSshStoreMock = <T,>(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<ReturnType<Window['setTimeout']>, { 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([]);
});
});
File diff suppressed because it is too large Load Diff
@@ -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 (
<>
@@ -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("<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} directory={terminalTab.targetDirectory} />");
expect(renderBlock).not.toContain('directory={activeTab?.targetDirectory}');
expect(renderBlock).not.toContain('directory={effectiveDirectory}');
});
});
@@ -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: <T,>(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(
<I18nProvider>
<ProjectActionsSection projectRef={{ id: 'project-1', path: '/repo' }} />
</I18nProvider>,
);
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<HTMLButtonElement>('button[aria-label="Working directory for this action"]');
expect(runInTrigger?.textContent).toContain('Current worktree');
expect(runInTrigger?.textContent).not.toContain('__project__');
});
});
@@ -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<ProjectActionsSectionProps> = ({ pr
/>
</div>
<div className="py-1">
<div className="mb-0.5 flex items-center gap-2">
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.runIn.label')}</p>
<SettingsInfoHint contentClassName="max-w-xs">
{t('settings.projects.actions.runIn.info')}
</SettingsInfoHint>
</div>
<Select
value={action.runIn === 'parent' ? PROJECT_RUN_IN_PARENT_VALUE : 'worktree'}
onValueChange={(value) => {
updateAction(action.id, (current) => {
if (value === PROJECT_RUN_IN_PARENT_VALUE) {
return { ...current, runIn: 'parent' };
}
return { ...current, runIn: undefined };
});
}}
>
<SelectTrigger
size={SETTINGS_SELECT_SIZE}
className={SETTINGS_SELECT_TRIGGER_CLASS}
aria-label={t('settings.projects.actions.runIn.aria')}
>
<SelectValue>
{(value) => value === 'worktree'
? t('settings.projects.actions.runIn.worktree')
: t('settings.projects.actions.runIn.project')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={PROJECT_RUN_IN_PARENT_VALUE}>{t('settings.projects.actions.runIn.project')}</SelectItem>
<SelectItem value="worktree">{t('settings.projects.actions.runIn.worktree')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="py-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="typography-ui-label text-foreground">{t('settings.projects.actions.field.autoOpenUrl')}</span>
@@ -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(
<TerminalViewport
sessionKey="session-1"
chunks={chunks}
onInput={() => 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);
});
});
@@ -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<TerminalController, Props>(({
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]);
@@ -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);
});
}
});
@@ -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),
};
}
@@ -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 = <T,>(selector: (state: typeof sessionUiState) => T): T => selector(sessionUiState);
const uiState = {
terminalFontSize: 14,
terminalShell: 'zsh',
terminalLoginShells: ['zsh'],
showTerminalQuickKeysOnDesktop: false,
openContextPreview,
};
const useUiStoreMock = Object.assign(
<T,>(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<HTMLElement>('[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);
});
});
@@ -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<TerminalViewProps> = ({ visible }) => {
export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
@@ -51,15 +58,19 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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 <Icon name={tabIconName} className="h-4 w-4" />;
const showProjectActionSpinner = tab.purpose.type === 'project-action'
&& ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle);
const tabIconName = showProjectActionSpinner
? 'loader-4'
: resolveTabIconName(tab.iconKey);
return (
<Icon
name={tabIconName}
className={cn(
'h-4 w-4',
showProjectActionSpinner && 'animate-spin text-muted-foreground motion-reduce:animate-none'
)}
/>
);
})(),
id: tab.id,
label: tab.label,
@@ -101,9 +124,10 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ visible }) => {
const activeTerminalIdRef = React.useRef<string | null>(null);
const activeTabIdRef = React.useRef<string | null>(activeTabId);
const terminalIdRef = React.useRef<string | null>(terminalSessionId);
const directoryRef = React.useRef<string | null>(effectiveDirectory);
const directoryRef = React.useRef<string | null>(terminalDirectory);
const terminalControllerRef = React.useRef<TerminalController | null>(null);
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const pendingTerminalCreatesRef = React.useRef(new Set<string>());
@@ -173,52 +197,32 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ visible }) => {
return;
}
if (!effectiveDirectory) {
if (!terminalDirectory) {
setConnectionError(
hasActiveContext
? t('terminalView.empty.noWorkingDirectory')
@@ -443,11 +447,14 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ visible }) => {
};
}, [
hasActiveContext,
effectiveDirectory,
terminalDirectory,
hasExplicitTerminalTarget,
terminalSessionId,
terminalLifecycle,
activeTabId,
@@ -613,10 +618,11 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ 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<TerminalViewProps> = ({ visible }) => {
);
}
if (!effectiveDirectory) {
if (!terminalDirectory) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<p>{t('terminalView.empty.noWorkingDirectoryForSession')}</p>
@@ -1077,7 +1083,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
</Button>
<div className="flex shrink-0 items-center gap-1 overflow-visible">
<Button type="button" size="xs" variant="ghost" className="h-7 w-7 p-0" onClick={() => void handleRestart()} disabled={isRestarting} title={t('terminalView.actions.restart')} aria-label={t('terminalView.actions.restart')}>
<Button type="button" size="xs" variant="ghost" className="h-7 w-7 p-0" onClick={() => void handleRestart()} disabled={isRestarting || isActionTab} title={t('terminalView.actions.restart')} aria-label={t('terminalView.actions.restart')}>
<Icon name="restart" className="h-4 w-4" />
</Button>
<Button
@@ -1098,8 +1104,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
variant="outline"
className="h-6 shrink-0 gap-1 px-2"
onClick={() => {
if (!effectiveDirectory) return;
openContextPreview(effectiveDirectory, previewUrl);
if (!contextDirectory) return;
openContextPreview(contextDirectory, previewUrl);
}}
title={t('terminalView.preview.openTitle')}
>
@@ -32,8 +32,8 @@ const viewportKeyDeclaration = terminalViewSource
.find((line) => line.includes('const terminalViewportKey =')) ?? '';
describe('terminal viewport remount guard', () => {
test('viewport identity excludes the PTY session id', () => {
expect(viewportKeyDeclaration).toContain('effectiveDirectory');
test('viewport identity uses the authoritative terminal directory and excludes the PTY session id', () => {
expect(viewportKeyDeclaration).toContain('terminalDirectory');
expect(viewportKeyDeclaration).toContain('activeTabId');
expect(viewportKeyDeclaration).not.toContain('terminalSessionId');
});
@@ -0,0 +1,76 @@
import { describe, expect, test } from 'bun:test';
import { resolveProjectActionsOwner } from './useProjectActionsContext';
const projects = [
{ id: 'openchamber', path: '/workspace/openchamber', label: 'OpenChamber' },
];
describe('resolveProjectActionsOwner', () => {
test('resolves a worktree directory to its owning parent project', () => {
const owner = resolveProjectActionsOwner({
projects,
worktreesByProject: new Map([
['/workspace/openchamber', [{
path: '/workspace/openchamber-feature',
projectDirectory: '/workspace/openchamber',
branch: 'feature',
label: 'feature',
}]],
]),
directory: '/workspace/openchamber-feature',
activeProjectId: null,
});
expect(owner).toEqual(projects[0]);
});
test('resolves a directory under the project path to that project', () => {
const owner = resolveProjectActionsOwner({
projects,
worktreesByProject: new Map(),
directory: '/workspace/openchamber/packages/ui',
activeProjectId: null,
});
expect(owner).toEqual(projects[0]);
});
test('falls back to the active project when the directory does not resolve', () => {
const owner = resolveProjectActionsOwner({
projects,
worktreesByProject: new Map(),
directory: '/some/other/project',
activeProjectId: 'openchamber',
});
expect(owner).toEqual(projects[0]);
});
test('falls back to the active project when the directory is empty or null', () => {
expect(resolveProjectActionsOwner({
projects,
worktreesByProject: new Map(),
directory: '',
activeProjectId: 'openchamber',
})).toEqual(projects[0]);
expect(resolveProjectActionsOwner({
projects,
worktreesByProject: new Map(),
directory: null,
activeProjectId: 'openchamber',
})).toEqual(projects[0]);
});
test('returns null when the directory does not resolve and the active project is unknown', () => {
const owner = resolveProjectActionsOwner({
projects,
worktreesByProject: new Map(),
directory: '/some/other/project',
activeProjectId: 'missing-project',
});
expect(owner).toBeNull();
});
});
@@ -1,20 +1,47 @@
import React from 'react';
import type { ProjectEntry } from '@/lib/api/types';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSession } from '@/sync/sync-context';
import type { ProjectRef } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import type { WorktreeMetadata } from '@/types/worktree';
export interface ProjectActionsContext {
projectRef: ProjectRef;
directory: string;
}
interface ProjectActionsOwnerInput {
projects: ProjectEntry[];
worktreesByProject: Map<string, WorktreeMetadata[]>;
directory: string | null;
activeProjectId: string | null;
}
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
export const resolveProjectActionsOwner = ({
projects,
worktreesByProject,
directory,
activeProjectId,
}: ProjectActionsOwnerInput): ProjectEntry | null => {
const normalizedDirectory = normalize(directory ?? '');
if (normalizedDirectory) {
const sessionProject = resolveProjectForSessionDirectory(projects, worktreesByProject, normalizedDirectory);
if (sessionProject) {
return sessionProject;
}
}
return projects.find((project) => project.id === activeProjectId) ?? null;
};
/**
* Resolves the active project ref + working directory used by
* {@link ProjectActionsButton}. Directory priority mirrors the header:
@@ -22,12 +49,9 @@ const normalize = (value: string): string => {
* good context so the actions button doesn't flicker during session switches.
*/
export function useProjectActionsContext(): ProjectActionsContext | null {
const activeProject = useProjectsStore((state) => {
if (!state.activeProjectId) {
return null;
}
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
});
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSession = useSession(currentSessionId ?? null);
@@ -50,16 +74,22 @@ export function useProjectActionsContext(): ProjectActionsContext | null {
}, [currentSession?.directory]);
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory;
const ownerProject = React.useMemo(() => resolveProjectActionsOwner({
projects,
worktreesByProject,
directory: openDirectory,
activeProjectId,
}), [activeProjectId, openDirectory, projects, worktreesByProject]);
const actionDirectory = React.useMemo(
() => normalize(openDirectory || activeProject?.path || ''),
[activeProject?.path, openDirectory],
() => normalize(openDirectory || ownerProject?.path || ''),
[openDirectory, ownerProject?.path],
);
const activeProjectRef = React.useMemo<ProjectRef | null>(() => {
if (!activeProject) {
if (!ownerProject) {
return null;
}
return { id: activeProject.id, path: activeProject.path };
}, [activeProject]);
return { id: ownerProject.id, path: ownerProject.path };
}, [ownerProject]);
const lastContextRef = React.useRef<ProjectActionsContext | null>(null);
React.useEffect(() => {
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Window } from 'happy-dom';
import { useTerminalStore } from '@/stores/useTerminalStore';
const touchCalls: string[][] = [];
const terminal = {
touchSessions: async (sessionIds: string[]) => {
touchCalls.push(sessionIds);
},
};
mock.module('@/hooks/useRuntimeAPIs', () => ({
useRuntimeAPIs: () => ({ terminal }),
}));
const { useTerminalSessionKeepalive } = await import('./useTerminalSessionKeepalive');
const HookHarness = () => {
useTerminalSessionKeepalive();
return null;
};
describe('useTerminalSessionKeepalive', () => {
let windowInstance: Window;
let host: HTMLDivElement;
let root: Root;
const originalSetInterval = globalThis.setInterval;
const originalClearInterval = globalThis.clearInterval;
const scheduledIntervals = new Map<number, { delay: number; callback: () => void }>();
let nextIntervalId = 1;
beforeEach(() => {
windowInstance = new Window({ url: 'http://localhost/' });
scheduledIntervals.clear();
nextIntervalId = 1;
Object.assign(globalThis, {
window: windowInstance,
document: windowInstance.document,
navigator: windowInstance.navigator,
HTMLElement: windowInstance.HTMLElement,
Element: windowInstance.Element,
Node: windowInstance.Node,
Event: windowInstance.Event,
IS_REACT_ACT_ENVIRONMENT: true,
});
const windowSetInterval = windowInstance.setInterval.bind(windowInstance);
const windowClearInterval = windowInstance.clearInterval.bind(windowInstance);
const fakeSetInterval = (callback: TimerHandler, delay = 0, ...args: unknown[]) => {
const liveHandle = windowSetInterval(() => undefined, 0);
windowClearInterval(liveHandle);
const id = nextIntervalId;
nextIntervalId += 1;
const callbackFn = () => {
if (callback instanceof Function) {
callback(...args);
return;
}
new Function(String(callback))();
};
scheduledIntervals.set(id, {
delay,
callback: callbackFn,
});
return id;
};
const fakeClearInterval = (intervalId: number) => {
scheduledIntervals.delete(intervalId);
};
Object.defineProperty(globalThis, 'setInterval', {
configurable: true,
writable: true,
value: fakeSetInterval,
});
Object.defineProperty(globalThis, 'clearInterval', {
configurable: true,
writable: true,
value: fakeClearInterval,
});
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
touchCalls.length = 0;
useTerminalStore.getState().clearAll();
const interactiveTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabSessionId('/repo', interactiveTabId, 'interactive-session');
useTerminalStore.getState().setTabLifecycle('/repo', interactiveTabId, 'running');
const runningActionTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabPurpose('/repo', runningActionTabId, {
type: 'project-action',
actionId: 'build',
executionId: 'exec-1',
});
useTerminalStore.getState().setTabSessionId('/repo', runningActionTabId, 'action-session');
useTerminalStore.getState().setTabLifecycle('/repo', runningActionTabId, 'running');
const exitedTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabSessionId('/repo', exitedTabId, 'exited-session');
useTerminalStore.getState().setTabLifecycle('/repo', exitedTabId, 'exited');
});
afterEach(async () => {
await act(async () => root.unmount());
Object.defineProperty(globalThis, 'setInterval', {
configurable: true,
writable: true,
value: originalSetInterval,
});
Object.defineProperty(globalThis, 'clearInterval', {
configurable: true,
writable: true,
value: originalClearInterval,
});
useTerminalStore.getState().clearAll();
});
test('touches non-exited tab sessions immediately and on the interval, without a TerminalView mount', async () => {
await act(async () => {
root.render(React.createElement(HookHarness));
});
expect(touchCalls).toEqual([['interactive-session', 'action-session']]);
expect([...scheduledIntervals.values()].map((entry) => entry.delay)).toEqual([10 * 60 * 1000]);
await act(async () => {
[...scheduledIntervals.values()][0]?.callback();
});
expect(touchCalls).toEqual([
['interactive-session', 'action-session'],
['interactive-session', 'action-session'],
]);
});
test('skips touches while offline and clears the interval on unmount', async () => {
Object.defineProperty(globalThis.navigator, 'onLine', {
configurable: true,
get: () => false,
});
await act(async () => {
root.render(React.createElement(HookHarness));
});
expect(touchCalls).toEqual([]);
expect(scheduledIntervals.size).toBe(1);
await act(async () => {
root.unmount();
});
expect(scheduledIntervals.size).toBe(0);
});
});
/**
* Every app root that renders terminals must own keepalive itself: the server
* reaps sessions with no attached socket after 30 idle minutes, and the loop
* no longer lives in TerminalView. The mobile shell runs its own root and
* mounts neither desktop layout, so a missing call there silently reintroduces
* background PTY reaping (reviewer finding on the revisit fix).
*/
describe('terminal keepalive root coverage', () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootSources: Array<[string, string]> = [
['MainLayout', join(__dirname, '..', 'components', 'layout', 'MainLayout.tsx')],
['VSCodeLayout', join(__dirname, '..', 'components', 'layout', 'VSCodeLayout.tsx')],
['MobileApp shell', join(__dirname, '..', 'apps', 'MobileApp.tsx')],
];
for (const [rootName, sourcePath] of rootSources) {
test(`${rootName} mounts useTerminalSessionKeepalive`, () => {
const source = readFileSync(sourcePath, 'utf-8');
expect(source).toContain('useTerminalSessionKeepalive()');
});
}
});
@@ -0,0 +1,29 @@
import React from 'react';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useTerminalStore } from '@/stores/useTerminalStore';
const TERMINAL_SESSION_KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000;
export const useTerminalSessionKeepalive = (): void => {
const { terminal } = useRuntimeAPIs();
React.useEffect(() => {
if (!terminal.touchSessions) {
return;
}
const touch = () => {
if (globalThis.navigator?.onLine === false) return;
const ids: string[] = [];
for (const dirState of useTerminalStore.getState().sessions.values()) {
for (const tab of dirState.tabs) {
if (tab.terminalSessionId && tab.lifecycle !== 'exited') ids.push(tab.terminalSessionId);
}
}
if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {});
};
touch();
const interval = setInterval(touch, TERMINAL_SESSION_KEEPALIVE_INTERVAL_MS);
return () => clearInterval(interval);
}, [terminal]);
};
+25 -2
View File
@@ -23,8 +23,14 @@ export interface TerminalSession {
cols: number;
rows: number;
status: 'running' | 'exited' | 'error';
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurpose;
}
export type TerminalSessionPurpose =
| { type: 'terminal' }
| { type: 'project-action'; actionId: string; executionId: string };
export type TerminalShell = 'auto' | 'bash' | 'zsh' | 'sh' | 'fish' | 'pwsh' | 'powershell' | 'cmd' | 'dash' | 'ksh' | 'nu';
export interface TerminalShellOption {
@@ -46,13 +52,15 @@ export interface TerminalStreamEvent {
runtime?: 'node' | 'bun';
ptyBackend?: string;
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurpose;
}
export interface TerminalError extends Error {
code?: string;
}
export interface CreateTerminalOptions {
interface BaseCreateTerminalOptions {
cwd: string;
sessionId?: string;
cols?: number;
@@ -62,8 +70,21 @@ export interface CreateTerminalOptions {
terminalForeground?: string;
shell?: TerminalShell;
loginShell?: boolean;
purpose?: TerminalSessionPurpose;
}
interface InteractiveCreateTerminalOptions extends BaseCreateTerminalOptions {
mode?: 'interactive';
}
interface CommandCreateTerminalOptions extends BaseCreateTerminalOptions {
mode: 'command';
command: string;
}
export type CreateTerminalOptions = InteractiveCreateTerminalOptions | CommandCreateTerminalOptions;
export type RestartTerminalOptions = InteractiveCreateTerminalOptions;
export interface ResizeTerminalPayload {
sessionId: string;
cols: number;
@@ -85,6 +106,8 @@ export interface TerminalServerSession {
cwd: string;
status: 'running' | 'exited';
createdAt: number | null;
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurpose;
}
export interface TerminalAPI {
@@ -99,7 +122,7 @@ export interface TerminalAPI {
resize(payload: ResizeTerminalPayload): Promise<void>;
updateAppearance?(sessionId: string, appearance: Pick<CreateTerminalOptions, 'themeMode' | 'terminalBackground' | 'terminalForeground'>): Promise<void>;
close(sessionId: string): Promise<void>;
restartSession?(currentSessionId: string, options: CreateTerminalOptions): Promise<TerminalSession>;
restartSession?(currentSessionId: string, options: RestartTerminalOptions): Promise<TerminalSession>;
forceKill?(options: ForceKillOptions): Promise<void>;
}
@@ -449,6 +449,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'Aktionsname',
'settings.projects.actions.field.command': 'Befehl',
'settings.projects.actions.field.commandPlaceholder': 'z. B. bun run lint',
'settings.projects.actions.runIn.label': 'Ausführen in',
'settings.projects.actions.runIn.info': 'Legt fest, wo diese Aktion ausgeführt wird, wenn sie aus einem verknüpften Worktree gestartet wird.',
'settings.projects.actions.runIn.project': 'Übergeordneter Checkout',
'settings.projects.actions.runIn.worktree': 'Aktueller Worktree',
'settings.projects.actions.runIn.aria': 'Arbeitsverzeichnis dieser Aktion',
'settings.projects.actions.field.autoOpenUrl': 'URL automatisch öffnen',
'settings.projects.actions.field.autoOpenUrlForAria': 'URL für {title} automatisch öffnen',
'settings.projects.actions.field.autoOpenUrlDescription': 'URL aus der Ausgabe oder benutzerdefinierte URL unten öffnen',
@@ -470,6 +470,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'Action name',
'settings.projects.actions.field.command': 'Command',
'settings.projects.actions.field.commandPlaceholder': 'e.g. bun run lint',
'settings.projects.actions.runIn.label': 'Run in',
'settings.projects.actions.runIn.info': 'Choose where this action runs when started from a linked worktree.',
'settings.projects.actions.runIn.project': 'Parent checkout',
'settings.projects.actions.runIn.worktree': 'Current worktree',
'settings.projects.actions.runIn.aria': 'Working directory for this action',
'settings.projects.actions.field.autoOpenUrl': 'Auto-open URL',
'settings.projects.actions.field.autoOpenUrlForAria': 'Auto-open URL for {title}',
'settings.projects.actions.field.autoOpenUrlDescription': 'Open URL from output or custom URL below',
@@ -438,6 +438,11 @@ export const settingsDict = {
"settings.projects.actions.field.actionNamePlaceholder": "Nombre de la acción",
"settings.projects.actions.field.command": "Comando",
"settings.projects.actions.field.commandPlaceholder": "p. ej. bun run lint",
"settings.projects.actions.runIn.label": "Ejecutar en",
"settings.projects.actions.runIn.info": "Elige dónde se ejecuta esta acción cuando se inicia desde un worktree vinculado.",
"settings.projects.actions.runIn.project": "Checkout principal",
"settings.projects.actions.runIn.worktree": "Worktree actual",
"settings.projects.actions.runIn.aria": "Directorio de trabajo de esta acción",
"settings.projects.actions.field.autoOpenUrl": "Abrir URL automáticamente",
"settings.projects.actions.field.autoOpenUrlForAria": "Abrir URL automáticamente para {title}",
"settings.projects.actions.field.autoOpenUrlDescription": "Abrir URL desde la salida o la URL personalizada de abajo",
@@ -361,6 +361,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'Nom de l\'action',
'settings.projects.actions.field.command': 'Commande',
'settings.projects.actions.field.commandPlaceholder': 'p. ex. bun install',
'settings.projects.actions.runIn.label': 'Exécuter dans',
'settings.projects.actions.runIn.info': 'Choisissez où cette action s\'exécute lorsqu\'elle est lancée depuis un worktree lié.',
'settings.projects.actions.runIn.project': 'Checkout parent',
'settings.projects.actions.runIn.worktree': 'Worktree courant',
'settings.projects.actions.runIn.aria': 'Répertoire d\'exécution de cette action',
'settings.projects.actions.field.autoOpenUrl': 'Ouverture automatique de lURL',
'settings.projects.actions.field.autoOpenUrlForAria': 'Ouverture automatique de lURL pour {title}',
'settings.projects.actions.field.autoOpenUrlDescription': 'Ouvrir lURL détectée dans la sortie, ou lURL personnalisée ci-dessous',
@@ -471,6 +471,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'アクション名',
'settings.projects.actions.field.command': 'コマンド',
'settings.projects.actions.field.commandPlaceholder': '例: bun run lint',
'settings.projects.actions.runIn.label': '実行場所',
'settings.projects.actions.runIn.info': 'リンクされたワークツリーから起動したときにこのアクションを実行する場所を選択します。',
'settings.projects.actions.runIn.project': '親チェックアウト',
'settings.projects.actions.runIn.worktree': '現在のワークツリー',
'settings.projects.actions.runIn.aria': 'このアクションの作業ディレクトリ',
'settings.projects.actions.field.autoOpenUrl': 'URL を自動開く',
'settings.projects.actions.field.autoOpenUrlForAria': '{title} の URL を自動開く',
'settings.projects.actions.field.autoOpenUrlDescription': '出力または以下のカスタム URL から URL を開く',
@@ -438,6 +438,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': '작업 이름',
'settings.projects.actions.field.command': '명령어',
'settings.projects.actions.field.commandPlaceholder': '예: bun run lint',
'settings.projects.actions.runIn.label': '실행 위치',
'settings.projects.actions.runIn.info': '연결된 워크트리에서 시작할 때 이 작업을 실행할 위치를 선택합니다.',
'settings.projects.actions.runIn.project': '상위 체크아웃',
'settings.projects.actions.runIn.worktree': '현재 워크트리',
'settings.projects.actions.runIn.aria': '이 작업의 작업 디렉터리',
'settings.projects.actions.field.autoOpenUrl': 'URL 자동 열기',
'settings.projects.actions.field.autoOpenUrlForAria': '{title}의 URL 자동 열기',
'settings.projects.actions.field.autoOpenUrlDescription': '명령 출력에서 감지한 URL 또는 아래의 사용자 정의 URL을 엽니다',
@@ -1368,6 +1368,11 @@ export const settingsDict = {
'settings.projects.actions.field.autoOpenUrlForAria': 'Automatycznie otwieraj URL dla {title}',
'settings.projects.actions.field.command': 'Polecenie',
'settings.projects.actions.field.commandPlaceholder': 'np. bun run lint',
'settings.projects.actions.runIn.label': 'Uruchom w',
'settings.projects.actions.runIn.info': 'Wybierz, gdzie uruchamiać tę akcję po uruchomieniu z połączonego worktree.',
'settings.projects.actions.runIn.project': 'Nadrzędny checkout',
'settings.projects.actions.runIn.worktree': 'Bieżący worktree',
'settings.projects.actions.runIn.aria': 'Katalog roboczy tej akcji',
'settings.projects.actions.field.desktopSshForward': 'Przekierowanie SSH pulpitu',
'settings.projects.actions.field.iconAria': 'Ikona {icon}',
'settings.projects.actions.field.overrideUrlPlaceholder': 'Nadpisz URL (opcjonalnie)',
@@ -438,6 +438,11 @@ export const settingsDict = {
"settings.projects.actions.field.actionNamePlaceholder": "Nome da ação",
"settings.projects.actions.field.command": "Comando",
"settings.projects.actions.field.commandPlaceholder": "ex.: bun run lint",
"settings.projects.actions.runIn.label": "Executar em",
"settings.projects.actions.runIn.info": "Escolha onde esta ação é executada quando iniciada a partir de um worktree vinculado.",
"settings.projects.actions.runIn.project": "Checkout pai",
"settings.projects.actions.runIn.worktree": "Worktree atual",
"settings.projects.actions.runIn.aria": "Diretório de trabalho desta ação",
"settings.projects.actions.field.autoOpenUrl": "Abrir URL automaticamente",
"settings.projects.actions.field.autoOpenUrlForAria": "Abrir URL automaticamente para {title}",
"settings.projects.actions.field.autoOpenUrlDescription": "Abrir URL da saída ou a URL personalizada abaixo",
@@ -466,6 +466,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'Eylem adı',
'settings.projects.actions.field.command': 'Komut',
'settings.projects.actions.field.commandPlaceholder': 'örn. bun run lint',
'settings.projects.actions.runIn.label': 'Çalıştırma konumu',
'settings.projects.actions.runIn.info': 'Bağlı bir worktree\'den başlatıldığında bu eylemin nerede çalışacağını seçin.',
'settings.projects.actions.runIn.project': 'Üst checkout',
'settings.projects.actions.runIn.worktree': 'Geçerli worktree',
'settings.projects.actions.runIn.aria': 'Bu eylemin çalışma dizini',
'settings.projects.actions.field.autoOpenUrl': 'URL\'yi otomatik aç',
'settings.projects.actions.field.autoOpenUrlForAria': '{title} için URL\'yi otomatik aç',
'settings.projects.actions.field.autoOpenUrlDescription': 'Çıktıdaki URL\'yi veya aşağıdaki özel URL\'yi aç',
@@ -438,6 +438,11 @@ export const settingsDict = {
"settings.projects.actions.field.actionNamePlaceholder": "Назва дії",
"settings.projects.actions.field.command": "Команда",
"settings.projects.actions.field.commandPlaceholder": "напр. bun run lint",
"settings.projects.actions.runIn.label": "Запускати в",
"settings.projects.actions.runIn.info": "Виберіть, де запускати цю дію, коли її запущено з пов'язаного worktree.",
"settings.projects.actions.runIn.project": "Батьківський checkout",
"settings.projects.actions.runIn.worktree": "Поточний worktree",
"settings.projects.actions.runIn.aria": "Робоча тека для цієї дії",
"settings.projects.actions.field.autoOpenUrl": "Автоматичне відкриття URL",
"settings.projects.actions.field.autoOpenUrlForAria": "Автоматичне відкриття URL для {title}",
"settings.projects.actions.field.autoOpenUrlDescription": "Відкрити URL із виведення або власний URL нижче",
@@ -438,6 +438,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': '操作名称',
'settings.projects.actions.field.command': '命令',
'settings.projects.actions.field.commandPlaceholder': '例如 bun run lint',
'settings.projects.actions.runIn.label': '运行位置',
'settings.projects.actions.runIn.info': '选择从关联 worktree 启动时此操作的运行位置。',
'settings.projects.actions.runIn.project': '父检出目录',
'settings.projects.actions.runIn.worktree': '当前 worktree',
'settings.projects.actions.runIn.aria': '此操作的工作目录',
'settings.projects.actions.field.autoOpenUrl': '自动打开 URL',
'settings.projects.actions.field.autoOpenUrlForAria': '为 {title} 自动打开 URL',
'settings.projects.actions.field.autoOpenUrlDescription': '从输出中打开 URL,或使用下面的自定义 URL',
@@ -435,6 +435,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': '操作名稱',
'settings.projects.actions.field.command': '命令',
'settings.projects.actions.field.commandPlaceholder': '例如 bun run lint',
'settings.projects.actions.runIn.label': '執行位置',
'settings.projects.actions.runIn.info': '選擇從關聯 worktree 啟動時此動作的執行位置。',
'settings.projects.actions.runIn.project': '父檢出目錄',
'settings.projects.actions.runIn.worktree': '目前 worktree',
'settings.projects.actions.runIn.aria': '此動作的工作目錄',
'settings.projects.actions.field.autoOpenUrl': '自動開啟 URL',
'settings.projects.actions.field.autoOpenUrlForAria': '為 {title} 自動開啟 URL',
'settings.projects.actions.field.autoOpenUrlDescription': '從輸出中開啟 URL,或使用下面的自訂 URL',
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { createProjectIdFromPath } from './projectId';
const homeDirectory = '/Users/test';
const project = { id: 'openchamber', path: '/workspace/openchamber' };
let files = new Map<string, string>();
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: mock(() => ({
files: {
createDirectory: mock(async () => ({ success: true })),
readFile: mock(async (path: string) => ({ content: files.get(path) ?? '' })),
writeFile: mock(async (path: string, content: string) => {
files.set(path, content);
return { success: true };
}),
delete: mock(async (path: string) => {
files.delete(path);
}),
},
})),
}));
mock.module('@/lib/desktop', () => ({
getDesktopHomeDirectory: mock(async () => homeDirectory),
isVSCodeRuntime: mock(() => false),
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: mock(async (url: string) => {
if (url.endsWith('/fs/home')) {
return new Response(JSON.stringify({ home: homeDirectory }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
});
}),
}));
const {
getProjectActionsState,
saveProjectActionsState,
} = await import('./openchamberConfig');
const getConfigPath = (projectPath: string): string => (
`${homeDirectory}/.config/openchamber/projects/${createProjectIdFromPath(projectPath)}.json`
);
describe('project actions config sanitization', () => {
beforeEach(() => {
files = new Map();
});
test('round-trips runIn parent through saved project actions state', async () => {
const saved = await saveProjectActionsState(project, {
actions: [{
id: 'action-1',
name: 'Run action',
command: 'pnpm dev',
runIn: 'parent',
}],
primaryActionId: 'action-1',
});
expect(saved).toBe(true);
const state = await getProjectActionsState(project);
expect(state).toEqual({
actions: [{
id: 'action-1',
name: 'Run action',
command: 'pnpm dev',
icon: null,
runIn: 'parent',
}],
primaryActionId: 'action-1',
});
});
test('keeps runIn omitted when saving project actions in the current worktree', async () => {
const saved = await saveProjectActionsState(project, {
actions: [{
id: 'action-1',
name: 'Run action',
command: 'pnpm dev',
}],
primaryActionId: 'action-1',
});
expect(saved).toBe(true);
const state = await getProjectActionsState(project);
expect(state).toEqual({
actions: [{
id: 'action-1',
name: 'Run action',
command: 'pnpm dev',
icon: null,
}],
primaryActionId: 'action-1',
});
});
test('normalizes runIn worktree to omission when loading project actions state', async () => {
files.set(getConfigPath(project.path), JSON.stringify({
projectPath: project.path,
projectActions: [
{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'worktree' },
],
projectActionsPrimaryId: 'action-1',
}));
const state = await getProjectActionsState(project);
expect(state).toEqual({
actions: [
{ id: 'action-1', name: 'Run action', command: 'pnpm dev', icon: null },
],
primaryActionId: 'action-1',
});
});
test('omits unsupported runIn values when loading project actions state', async () => {
files.set(getConfigPath(project.path), JSON.stringify({
projectPath: project.path,
projectActions: [
{ id: 'action-project', name: 'Project', command: 'pnpm dev', runIn: 'project' },
{ id: 'action-number', name: 'Number', command: 'pnpm test', runIn: 123 },
],
projectActionsPrimaryId: 'action-project',
}));
const state = await getProjectActionsState(project);
expect(state).toEqual({
actions: [
{ id: 'action-project', name: 'Project', command: 'pnpm dev', icon: null },
{ id: 'action-number', name: 'Number', command: 'pnpm test', icon: null },
],
primaryActionId: 'action-project',
});
});
});
+9 -2
View File
@@ -50,6 +50,7 @@ export interface OpenChamberProjectAction {
name: string;
command: string;
icon?: string | null;
runIn?: 'parent';
platforms?: OpenChamberProjectActionPlatform[];
autoOpenUrl?: boolean;
openUrl?: string;
@@ -280,6 +281,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
name?: unknown;
command?: unknown;
icon?: unknown;
runIn?: unknown;
platforms?: unknown;
autoOpenUrl?: unknown;
openUrl?: unknown;
@@ -296,6 +298,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
seenIds.add(id);
const iconRaw = typeof record.icon === 'string' ? record.icon.trim() : '';
const runIn = record.runIn === 'parent' ? 'parent' : undefined;
const platforms = sanitizeProjectActionPlatforms(record.platforms);
const autoOpenUrl = record.autoOpenUrl === true;
const openUrlRaw = typeof record.openUrl === 'string' ? record.openUrl.trim() : '';
@@ -308,7 +311,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH
);
sanitized.push({
const sanitizedAction: OpenChamberProjectAction = {
id,
name,
command,
@@ -317,7 +320,11 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
...(openUrl ? { openUrl } : {}),
...(desktopOpenSshForward ? { desktopOpenSshForward } : {}),
...(platforms.length > 0 ? { platforms } : {}),
});
};
if (runIn) {
sanitizedAction.runIn = runIn;
}
sanitized.push(sanitizedAction);
}
return sanitized;
+259 -12
View File
@@ -1,7 +1,12 @@
import { describe, expect, test } from 'bun:test';
import type { TerminalAPI, TerminalHandlers } from './api/types';
import { waitForTerminalExit } from './projectActionTerminal';
import { detectDevServerCommand } from './detectDevServer';
import {
createProjectActionTerminalSession,
normalizeProjectActionCommand,
reconcileTerminalSessionAuthority,
stopProjectActionTerminalSession,
waitForTerminalExit,
} from './projectActionTerminal';
const fakeTerminal = () => {
let handlers: TerminalHandlers | null = null;
@@ -15,16 +20,6 @@ const fakeTerminal = () => {
};
describe('project action terminal lifecycle', () => {
test('preserves a configured dev action preview URL', async () => {
const detected = await detectDevServerCommand('/repo', [{
id: 'dev',
name: 'Dev server',
command: 'bun run dev',
openUrl: 'http://localhost:4321',
}], null);
expect(detected?.previewUrlHint).toBe('http://localhost:4321');
});
test('resolves on live exit and closes its temporary subscription', async () => {
const fake = fakeTerminal();
const result = waitForTerminalExit(fake.terminal, 'term-1', 100);
@@ -45,4 +40,256 @@ describe('project action terminal lifecycle', () => {
expect(await waitForTerminalExit(fake.terminal, 'term-1', 5)).toBe(false);
expect(fake.isClosed()).toBe(true);
});
test('normalizes a project action command before create', () => {
expect(normalizeProjectActionCommand(' printf "hi"\r\nexit\u0007 ')).toBe('printf "hi"\nexit');
});
test('closes the previous session before creating a command-mode run', async () => {
const calls: string[] = [];
const terminal: TerminalAPI = {
createSession: async (options) => {
calls.push(`create:${JSON.stringify(options)}`);
return { sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } };
},
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async (sessionId) => {
calls.push(`close:${sessionId}`);
},
};
const created = await createProjectActionTerminalSession({
terminal,
previousSessionId: 'stale-session',
createOptions: {
cwd: '/repo',
sessionId: 'tab-1',
},
command: 'echo hello',
isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
expect(created).toEqual({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
expect(calls).toEqual([
'close:stale-session',
'create:{"cwd":"/repo","sessionId":"tab-1","mode":"command","command":"echo hello","purpose":{"type":"project-action","actionId":"build","executionId":"exec-1"}}',
]);
});
test('rejects and closes a create response that does not echo command mode', async () => {
const closed: string[] = [];
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async (sessionId) => {
closed.push(sessionId);
},
};
await expect(createProjectActionTerminalSession({
terminal,
previousSessionId: null,
createOptions: {
cwd: '/repo',
sessionId: 'tab-1',
},
command: 'echo hello',
isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('COMMAND_MODE_UNSUPPORTED');
expect(closed).toEqual(['tab-1']);
});
test('closes a newly created command session when stop removes the run during create', async () => {
const closed: string[] = [];
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async (sessionId) => {
closed.push(sessionId);
},
};
await expect(createProjectActionTerminalSession({
terminal,
previousSessionId: null,
createOptions: {
cwd: '/repo',
sessionId: 'tab-1',
},
command: 'echo hello',
isRunStillExpected: () => false,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED');
expect(closed).toEqual(['tab-1']);
});
test('rejects and closes a create response that does not echo project-action purpose', async () => {
const closed: string[] = [];
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async (sessionId) => {
closed.push(sessionId);
},
};
await expect(createProjectActionTerminalSession({
terminal,
previousSessionId: null,
createOptions: { cwd: '/repo', sessionId: 'tab-1' },
command: 'echo hello',
isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('PROJECT_ACTION_PURPOSE_UNSUPPORTED');
expect(closed).toEqual(['tab-1']);
});
test('reuses one in-flight authority listing per directory', async () => {
let calls = 0;
let resolveSessions: ((value: Array<{ sessionId: string; cwd: string; status: 'running'; createdAt: number | null }>) => void) | undefined;
const capturedRevisions: number[] = [];
const terminal: TerminalAPI = {
listSessions: async () => {
calls += 1;
return await new Promise((resolve) => {
resolveSessions = resolve;
});
},
createSession: async () => ({ sessionId: 'ignored', cols: 80, rows: 24, status: 'running' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async () => {},
};
const captureStartedActionMutationRevisions = () => {
const revision = capturedRevisions.length + 1;
capturedRevisions.push(revision);
return new Map([['/repo::build', revision]]);
};
const first = reconcileTerminalSessionAuthority(terminal, '/repo', {
captureStartedActionMutationRevisions,
});
const second = reconcileTerminalSessionAuthority(terminal, '/repo', {
captureStartedActionMutationRevisions,
});
expect(first).toBe(second);
const finishListing = resolveSessions;
if (!finishListing) {
throw new Error('list resolver was not captured');
}
finishListing([{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 1 }]);
expect(await first).toEqual({
sessions: [{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 1 }],
startedActionMutationRevisions: new Map([['/repo::build', 1]]),
});
expect(calls).toBe(1);
expect(capturedRevisions).toEqual([1]);
});
test('does not share an authority listing across runtime adapters', async () => {
const calls: string[] = [];
const createTerminal = (name: string): TerminalAPI => ({
listSessions: async () => {
calls.push(name);
return [];
},
createSession: async () => ({ sessionId: 'ignored', cols: 80, rows: 24, status: 'running' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async () => {},
});
await Promise.all([
reconcileTerminalSessionAuthority(createTerminal('runtime-a'), '/repo'),
reconcileTerminalSessionAuthority(createTerminal('runtime-b'), '/repo'),
]);
expect(calls).toEqual(['runtime-a', 'runtime-b']);
});
test('stale stop completion does not interrupt or force-kill a newer execution and cleanup runs once', async () => {
const sent: string[] = [];
const forceKillCalls: string[] = [];
const subscriptions: Array<{ handlers: TerminalHandlers; closed: number }> = [];
let current = true;
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'unused', cols: 80, rows: 24, status: 'running' }),
connect: (_id, handlers) => {
const record = { handlers, closed: 0 };
subscriptions.push(record);
return { close: () => { record.closed += 1; } };
},
sendInput: async (sessionId, input) => {
sent.push(`${sessionId}:${input}`);
current = false;
},
resize: async () => {},
close: async () => {},
forceKill: async ({ sessionId }) => {
forceKillCalls.push(sessionId ?? '');
},
};
let stopping = 0;
let restored = 0;
let cleared = 0;
let finalized = 0;
await stopProjectActionTerminalSession({
terminal,
sessionId: 'srv-1',
isExecutionStillCurrent: () => current,
markStopping: () => { stopping += 1; },
restoreRunning: () => { restored += 1; },
clearSession: () => { cleared += 1; },
finalizeExit: () => { finalized += 1; },
timeoutMs: 1,
});
expect(sent).toEqual(['srv-1:\x03']);
expect(forceKillCalls).toEqual([]);
expect(stopping).toBe(1);
expect(restored).toBe(0);
expect(cleared).toBe(0);
expect(finalized).toBe(0);
expect(subscriptions).toHaveLength(1);
expect(subscriptions[0]?.closed).toBe(1);
});
test('stop failure returns the action to a retryable running state', async () => {
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'unused', cols: 80, rows: 24, status: 'running' }),
connect: (_id, handlers) => ({ close: () => { handlers.onError?.(new Error('ignored'), false); } }),
sendInput: async () => {},
resize: async () => {},
close: async () => { throw new Error('close failed'); },
};
let restored = 0;
let finalized = 0;
await stopProjectActionTerminalSession({
terminal,
sessionId: 'srv-1',
isExecutionStillCurrent: () => true,
markStopping: () => undefined,
restoreRunning: () => { restored += 1; },
clearSession: () => undefined,
finalizeExit: () => { finalized += 1; },
timeoutMs: 1,
});
expect(restored).toBe(1);
expect(finalized).toBe(0);
});
});
+231 -1
View File
@@ -1,4 +1,129 @@
import type { TerminalAPI } from './api/types';
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
const normalizeDirectory = (dir: string): string => {
let normalized = dir.trim();
while (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
};
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command'>;
type CreateProjectActionTerminalSessionOptions = {
terminal: TerminalAPI;
previousSessionId: string | null;
createOptions: ProjectActionTerminalCreateOptions;
command: string;
isRunStillExpected: () => boolean;
purpose: Extract<TerminalSessionPurpose, { type: 'project-action' }>;
};
type StopProjectActionTerminalSessionOptions = {
terminal: TerminalAPI;
sessionId: string;
isExecutionStillCurrent: () => boolean;
markStopping: () => void;
restoreRunning: () => void;
clearSession: () => void;
finalizeExit: () => void;
timeoutMs?: number;
};
const COMMAND_MODE_UNSUPPORTED_ERROR = 'COMMAND_MODE_UNSUPPORTED';
const PROJECT_ACTION_RUN_CANCELLED_ERROR = 'PROJECT_ACTION_RUN_CANCELLED';
const PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR = 'PROJECT_ACTION_PURPOSE_UNSUPPORTED';
const createProjectActionTerminalError = (message: string): Error => new Error(message);
const closeTerminalSession = async (terminal: TerminalAPI, sessionId: string): Promise<void> => {
try {
await terminal.close(sessionId);
} catch {
// noop
}
};
const rejectCreatedSession = async (terminal: TerminalAPI, sessionId: string, errorMessage: string): Promise<never> => {
await closeTerminalSession(terminal, sessionId);
throw createProjectActionTerminalError(errorMessage);
};
export const normalizeProjectActionCommand = (command: string): string => {
const normalizedNewlines = command.trim().replace(/\r\n|\r/g, '\n');
let next = '';
for (let index = 0; index < normalizedNewlines.length; index += 1) {
const code = normalizedNewlines.charCodeAt(index);
const isControl = (code >= 0 && code <= 8)
|| code === 11
|| code === 12
|| (code >= 14 && code <= 31)
|| code === 127;
if (!isControl) {
next += normalizedNewlines[index];
}
}
return next;
};
const isCommandTerminalSession = (session: TerminalSession): boolean => session.mode === 'command';
const isProjectActionTerminalPurpose = (
purpose: TerminalSessionPurpose | undefined,
): purpose is Extract<TerminalSessionPurpose, { type: 'project-action' }> => purpose?.type === 'project-action';
const isMatchingProjectActionPurpose = (
purpose: TerminalSessionPurpose | undefined,
actionId: string,
): purpose is Extract<TerminalSessionPurpose, { type: 'project-action' }> => (
isProjectActionTerminalPurpose(purpose)
&& purpose.actionId === actionId
&& purpose.executionId.trim().length > 0
);
type ReconcileTerminalSessionAuthorityOptions = {
captureStartedActionMutationRevisions?: (directory: string) => TerminalActionMutationRevisions;
};
type ReconcileTerminalSessionAuthorityResult = {
sessions: TerminalServerSession[];
startedActionMutationRevisions: TerminalActionMutationRevisions;
};
export const createProjectActionTerminalSession = async ({
terminal,
previousSessionId,
createOptions,
command,
isRunStillExpected,
purpose,
}: CreateProjectActionTerminalSessionOptions): Promise<TerminalSession> => {
if (previousSessionId) {
await closeTerminalSession(terminal, previousSessionId);
}
const created = await terminal.createSession({
...createOptions,
mode: 'command',
command: normalizeProjectActionCommand(command),
purpose,
});
if (!isCommandTerminalSession(created)) {
await rejectCreatedSession(terminal, created.sessionId, COMMAND_MODE_UNSUPPORTED_ERROR);
}
if (!isMatchingProjectActionPurpose(created.purpose, purpose.actionId)) {
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR);
}
if (!isRunStillExpected()) {
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_RUN_CANCELLED_ERROR);
}
return created;
};
export const waitForTerminalExit = (
terminal: TerminalAPI,
@@ -24,3 +149,108 @@ export const waitForTerminalExit = (
if (settled) subscription.close();
else timeout = setTimeout(() => finish(false), timeoutMs);
});
export const stopProjectActionTerminalSession = async ({
terminal,
sessionId,
isExecutionStillCurrent,
markStopping,
restoreRunning,
clearSession,
finalizeExit,
timeoutMs = 1000,
}: StopProjectActionTerminalSessionOptions): Promise<void> => {
markStopping();
const exitPromise = waitForTerminalExit(terminal, sessionId, timeoutMs);
try {
if (isExecutionStillCurrent()) {
await terminal.sendInput(sessionId, '\x03');
}
} catch {
// noop
}
const exitObserved = await exitPromise;
if (!isExecutionStillCurrent()) {
return;
}
if (!exitObserved) {
let terminationFailed = false;
if (terminal.forceKill) {
try {
if (isExecutionStillCurrent()) {
await terminal.forceKill({ sessionId });
}
} catch {
terminationFailed = true;
}
} else {
try {
if (isExecutionStillCurrent()) {
await terminal.close(sessionId);
}
} catch {
terminationFailed = true;
}
}
if (!isExecutionStillCurrent()) {
return;
}
if (terminationFailed) {
restoreRunning();
return;
}
clearSession();
}
if (!isExecutionStillCurrent()) {
return;
}
finalizeExit();
};
const reconcileFlightsByTerminal = new WeakMap<
TerminalAPI,
Map<string, Promise<ReconcileTerminalSessionAuthorityResult | null>>
>();
export const reconcileTerminalSessionAuthority = (
terminal: TerminalAPI,
directory: string,
options: ReconcileTerminalSessionAuthorityOptions = {},
): Promise<ReconcileTerminalSessionAuthorityResult | null> => {
if (!terminal.listSessions) {
return Promise.resolve(null);
}
const normalizedDirectory = normalizeDirectory(directory);
let terminalFlights = reconcileFlightsByTerminal.get(terminal);
if (!terminalFlights) {
terminalFlights = new Map();
reconcileFlightsByTerminal.set(terminal, terminalFlights);
}
const existing = terminalFlights.get(normalizedDirectory);
if (existing) {
return existing;
}
const startedActionMutationRevisions = options.captureStartedActionMutationRevisions?.(normalizedDirectory)
?? new Map<string, number>();
const flight = terminal.listSessions(normalizedDirectory)
.then((sessions) => ({ sessions, startedActionMutationRevisions }))
.catch(() => null)
.finally(() => {
if (terminalFlights.get(normalizedDirectory) === flight) {
terminalFlights.delete(normalizedDirectory);
if (terminalFlights.size === 0) {
reconcileFlightsByTerminal.delete(terminal);
}
}
});
terminalFlights.set(normalizedDirectory, flight);
return flight;
};
@@ -18,4 +18,22 @@ describe('resolveProjectForSessionDirectory', () => {
expect(resolveProjectForSessionDirectory(projects, worktrees, '/workspace/openchamber-feature')).toEqual(projects[0]);
});
test('prefers registered worktree ownership over a containing project', () => {
const configuredProjects = [
{ id: 'home', path: '/Users/elfy', label: 'Home' },
{ id: 'infoscan', path: '/Users/elfy/GitRepos/infoscan', label: 'InfoScan' },
];
const worktreePath = '/Users/elfy/.local/share/opencode/worktree/refactor-self-hosted-runners';
const worktrees = new Map([
['/Users/elfy/GitRepos/infoscan', [{
path: worktreePath,
projectDirectory: '/Users/elfy/GitRepos/infoscan',
branch: 'refactor/self-hosted-runners',
label: 'refactor/self-hosted-runners',
}]],
]);
expect(resolveProjectForSessionDirectory(configuredProjects, worktrees, worktreePath)).toEqual(configuredProjects[1]);
});
});
+14 -6
View File
@@ -24,7 +24,7 @@ const resolveProjectFromWorktreeDirectory = (
projects: ProjectEntry[],
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null => {
): { project: ProjectEntry; matchedWorktreePathLength: number } | null => {
const nd = normalizeProjectPath(directory);
if (!nd) return null;
let matchedWorktree: WorktreeMetadata | null = null;
@@ -47,9 +47,9 @@ const resolveProjectFromWorktreeDirectory = (
.filter((v): v is string => Boolean(v));
for (const c of candidates) {
const exact = projects.find((p) => normalizeProjectPath(p.path) === c) ?? null;
if (exact) return exact;
if (exact) return { project: exact, matchedWorktreePathLength: bestLen };
const nested = resolveProjectForDirectory(projects, c);
if (nested) return nested;
if (nested) return { project: nested, matchedWorktreePathLength: bestLen };
}
return null;
};
@@ -58,6 +58,14 @@ export const resolveProjectForSessionDirectory = (
projects: ProjectEntry[],
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null =>
resolveProjectForDirectory(projects, directory) ??
resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory);
): ProjectEntry | null => {
const directProject = resolveProjectForDirectory(projects, directory);
const worktreeResolution = resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory);
if (!directProject) return worktreeResolution?.project ?? null;
if (!worktreeResolution) return directProject;
const directPathLength = normalizeProjectPath(directProject.path)?.length ?? 0;
return worktreeResolution.matchedWorktreePathLength > directPathLength
? worktreeResolution.project
: directProject;
};
+115 -26
View File
@@ -1,23 +1,53 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import type { TerminalSessionPurpose, TerminalStreamEvent } from './api/types';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { TerminalTransport } from './terminalApi';
mock.module('./runtime-fetch', () => ({ runtimeFetch: async () => new Response(null, { status: 500 }) }));
mock.module('./runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: () => 'ws://example.test/terminal' }) }));
mock.module('./runtime-auth', () => ({
clearRuntimeUrlAuthToken: () => undefined,
refreshRuntimeUrlAuthToken: async () => undefined,
}));
mock.module('./relay/runtime-socket', () => ({ openRuntimeWebSocket: () => { throw new Error('not used in tests'); } }));
const { parseTerminalSession, parseTerminalSessionPurpose, TerminalTransport } = await import('./terminalApi');
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const frame = (message: Record<string, unknown>): Uint8Array => {
type WireMessage = {
t: string;
s?: string;
q?: number;
v?: number;
d?: string;
r?: string;
history?: string;
status?: TerminalStreamEvent['status'];
exitCode?: number;
signal?: number | null;
code?: string;
message?: string;
fatal?: boolean;
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurpose | { type: 'project-action'; actionId: string };
};
const frame = (message: WireMessage): Uint8Array => {
const body = encoder.encode(JSON.stringify(message));
const result = new Uint8Array(body.length + 1);
result[0] = 1;
result.set(body, 1);
return result;
};
const parseFrame = (value: string | ArrayBuffer | ArrayBufferView): Record<string, unknown> => {
const bytes = typeof value === 'string'
? encoder.encode(value)
: value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
return JSON.parse(decoder.decode(bytes.subarray(1))) as Record<string, unknown>;
const parseFrame = (value: string | ArrayBuffer | ArrayBufferView): WireMessage => {
const bytes = value instanceof ArrayBuffer
? new Uint8Array(value)
: ArrayBuffer.isView(value)
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
: encoder.encode(value);
const parsed = JSON.parse(decoder.decode(bytes.subarray(1)));
// SAFETY: test frames are encoded from `WireMessage`, so decoding that same frame preserves the wire shape here.
return parsed as WireMessage;
};
class FakeSocket implements RelayTunnelWebSocket {
@@ -27,12 +57,12 @@ class FakeSocket implements RelayTunnelWebSocket {
onmessage: RelayTunnelWebSocket['onmessage'] = null;
onerror: (() => void) | null = null;
onclose: RelayTunnelWebSocket['onclose'] = null;
sent: Record<string, unknown>[] = [];
sent: WireMessage[] = [];
open(): void { this.readyState = 1; this.onopen?.(); }
emit(message: Record<string, unknown>): void {
emit(message: WireMessage): void {
const bytes = frame(message);
this.onmessage?.({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer });
this.onmessage?.({ data: bytes.slice().buffer });
}
send(data: string | ArrayBuffer | ArrayBufferView): void { this.sent.push(parseFrame(data)); }
close(): void { this.readyState = 3; this.onclose?.({ code: 1000, reason: '' }); }
@@ -41,6 +71,38 @@ class FakeSocket implements RelayTunnelWebSocket {
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('terminal transport', () => {
test('parses the terminal purpose union and rejects malformed payloads', () => {
expect(parseTerminalSessionPurpose({ type: 'terminal' })).toEqual({ type: 'terminal' });
expect(parseTerminalSessionPurpose({ type: 'project-action', actionId: 'build', executionId: 'exec-1' })).toEqual({
type: 'project-action',
actionId: 'build',
executionId: 'exec-1',
});
expect(parseTerminalSessionPurpose({ type: 'project-action', actionId: 'build' })).toBe(undefined);
expect(parseTerminalSession({
sessionId: 'term-1',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).toEqual({
sessionId: 'term-1',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
expect(parseTerminalSession({
sessionId: 'term-1',
cols: 80,
rows: 24,
status: 'running',
purpose: { type: 'project-action', actionId: 'build' },
})).toBeNull();
});
test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
@@ -52,7 +114,7 @@ describe('terminal transport', () => {
expect(socket.sent.some((message) => message.t === 'attach' && message.s === 'term-1')).toBe(true);
expect(socket.sent.filter((message) => message.t === 'attach')).toHaveLength(1);
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running' });
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
await tick();
const secondEvents: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => secondEvents.push(`${event.type}:${event.data ?? ''}`) });
@@ -71,8 +133,8 @@ describe('terminal transport', () => {
});
test('recovers when opening the first websocket fails', async () => {
if (typeof document !== 'undefined') Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
if (typeof navigator !== 'undefined') Object.defineProperty(navigator, 'onLine', { configurable: true, value: true });
if (globalThis.document) Object.defineProperty(globalThis.document, 'visibilityState', { configurable: true, value: 'visible' });
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, 'onLine', { configurable: true, value: true });
const socket = new FakeSocket();
let attempts = 0;
const events: string[] = [];
@@ -165,7 +227,8 @@ describe('terminal transport', () => {
const unsubscribeFirst = transport.subscribe('term-1', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') firstEvents.push(event.attempt);
if (event.type !== 'reconnecting' || event.attempt == null) return;
firstEvents.push(event.attempt);
},
});
await tick();
@@ -175,7 +238,8 @@ describe('terminal transport', () => {
unsubscribeFirst();
const unsubscribeReplacement = transport.subscribe('term-2', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') replacementEvents.push(event.attempt);
if (event.type !== 'reconnecting' || event.attempt == null) return;
replacementEvents.push(event.attempt);
},
});
await tick();
@@ -190,7 +254,7 @@ describe('terminal transport', () => {
const originalSetTimeout = globalThis.setTimeout;
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const delays: number[] = [];
let transport: TerminalTransport | null = null;
let transport: InstanceType<typeof TerminalTransport> | null = null;
Object.defineProperty(globalThis, 'document', {
configurable: true,
@@ -200,11 +264,16 @@ describe('terminal transport', () => {
removeEventListener: () => {},
},
});
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
delays.push(Number(timeout ?? 0));
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
Object.defineProperty(globalThis, 'setTimeout', {
configurable: true,
value: (handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
delays.push(Number(timeout ?? 0));
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
const handle = originalSetTimeout(() => {}, 0);
clearTimeout(handle);
return handle;
},
});
try {
transport = new TerminalTransport({
@@ -218,9 +287,9 @@ describe('terminal transport', () => {
expect(delays).toContain(60_000);
} finally {
transport?.dispose();
globalThis.setTimeout = originalSetTimeout;
Object.defineProperty(globalThis, 'setTimeout', { configurable: true, value: originalSetTimeout });
if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument);
else delete (globalThis as { document?: unknown }).document;
else Reflect.deleteProperty(globalThis, 'document');
}
});
@@ -283,6 +352,26 @@ describe('terminal transport', () => {
transport.dispose();
});
test('preserves valid snapshot purpose and safely drops malformed snapshot purpose', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const purposes: Array<string | null> = [];
transport.subscribe('term-1', {
onEvent: (event) => {
if (event.type !== 'snapshot') return;
purposes.push(event.purpose?.type === 'project-action' ? event.purpose.executionId : null);
},
});
await tick();
socket.open();
await tick();
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
socket.emit({ t: 'restarted', v: 3, s: 'term-1', q: 2, history: 'prompt 2', purpose: { type: 'project-action', actionId: 'build' } });
await tick();
expect(purposes).toEqual(['exec-1', 'exec-1']);
transport.dispose();
});
test('reuses the open socket when switching between terminals', async () => {
const sockets: FakeSocket[] = [];
let authCalls = 0;
+103 -27
View File
@@ -1,17 +1,37 @@
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types';
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types';
import { openRuntimeWebSocket } from './relay/runtime-socket';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
import { isTerminalShell } from './terminalShell';
import { z } from 'zod';
type Message = Record<string, unknown> & { t: string; s?: string; q?: number };
type Message = Record<string, unknown> & {
t: string;
s?: string;
q?: number;
d?: string;
r?: string;
history?: string;
status?: TerminalStreamEvent['status'];
exitCode?: number;
signal?: number | null;
runtime?: TerminalStreamEvent['runtime'];
ptyBackend?: string;
mode?: TerminalSession['mode'];
purpose?: TerminalSessionPurposeInput;
message?: string;
code?: string;
fatal?: boolean;
};
type Subscriber = { handlers: TerminalHandlers; lastSequence: number };
type TerminalProjection = {
sequence: number;
history: string;
status: TerminalStreamEvent['status'];
mode?: TerminalSession['mode'];
purpose?: TerminalSessionPurpose;
exitCode?: number;
signal?: number | null;
runtime?: TerminalStreamEvent['runtime'];
@@ -30,6 +50,42 @@ const SOCKET_OPEN = 1;
const IDLE_SOCKET_GRACE_MS = 15_000;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const terminalModeSchema = z.enum(['interactive', 'command']);
const terminalStatusSchema = z.enum(['running', 'exited', 'error']);
const terminalRuntimeSchema = z.enum(['node', 'bun']);
type TerminalSessionPurposeInput =
| TerminalSessionPurpose
| { type: 'project-action'; actionId: string; executionId?: string }
| null
| undefined;
type TerminalSessionInput = {
sessionId?: string;
cols?: number;
rows?: number;
status?: 'running' | 'exited' | 'error';
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurposeInput;
} | null | undefined;
const terminalSessionPurposeSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('terminal') }),
z.object({ type: z.literal('project-action'), actionId: z.string(), executionId: z.string() }),
]);
const terminalSessionSchema = z.object({
sessionId: z.string(),
cols: z.number(),
rows: z.number(),
status: terminalStatusSchema,
mode: terminalModeSchema.optional(),
purpose: terminalSessionPurposeSchema.optional(),
});
const terminalServerSessionSchema = z.object({
sessionId: z.string(),
cwd: z.string(),
status: z.enum(['running', 'exited']),
createdAt: z.number().nullable().optional().transform((value) => value ?? null),
mode: terminalModeSchema.optional(),
purpose: terminalSessionPurposeSchema.optional(),
});
const encode = (message: Message): Uint8Array => {
const payload = encoder.encode(JSON.stringify(message));
@@ -63,6 +119,28 @@ const trimProjection = (value: string): string => {
return decoder.decode(bytes.subarray(start));
};
const terminalSessionListSchema = z.object({ sessions: z.array(z.unknown()) });
const parseTerminalMode = (value: TerminalSession['mode'] | null | undefined): TerminalSession['mode'] | undefined => {
return terminalModeSchema.safeParse(value).data;
};
const parseTerminalStatus = (value: TerminalStreamEvent['status'] | null | undefined): TerminalStreamEvent['status'] => {
return terminalStatusSchema.safeParse(value).data ?? 'running';
};
const parseTerminalRuntime = (value: TerminalStreamEvent['runtime'] | null | undefined): TerminalStreamEvent['runtime'] | undefined => {
return terminalRuntimeSchema.safeParse(value).data;
};
export const parseTerminalSessionPurpose = (value: TerminalSessionPurposeInput): TerminalSessionPurpose | undefined => {
return terminalSessionPurposeSchema.safeParse(value).data;
};
export const parseTerminalSession = (value: TerminalSessionInput): TerminalSession | null => {
return terminalSessionSchema.safeParse(value).data ?? null;
};
type TerminalTransportDependencies = {
refreshAuth: () => Promise<unknown>;
openSocket: () => RelayTunnelWebSocket;
@@ -98,7 +176,7 @@ export class TerminalTransport {
const projection = this.projections.get(sessionId);
if (projection) {
subscriber.lastSequence = projection.sequence;
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
}
const socketWasOpen = this.socket?.readyState === SOCKET_OPEN;
this.ensureConnected().then(() => {
@@ -267,18 +345,20 @@ export class TerminalTransport {
if (!subscribers) return;
if (message.t === 'snapshot') {
const projection: TerminalProjection = {
sequence: typeof message.q === 'number' ? message.q : 0,
history: typeof message.history === 'string' ? message.history : '',
status: message.status as TerminalStreamEvent['status'],
exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined,
signal: typeof message.signal === 'number' ? message.signal : null,
runtime: message.runtime as TerminalStreamEvent['runtime'],
ptyBackend: typeof message.ptyBackend === 'string' ? message.ptyBackend : undefined,
sequence: message.q ?? 0,
history: message.history ?? '',
status: parseTerminalStatus(message.status),
mode: parseTerminalMode(message.mode),
purpose: parseTerminalSessionPurpose(message.purpose),
exitCode: message.exitCode,
signal: message.signal ?? null,
runtime: parseTerminalRuntime(message.runtime),
ptyBackend: message.ptyBackend,
};
this.projections.set(message.s, projection);
for (const sub of subscribers) {
sub.lastSequence = projection.sequence;
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
}
return;
}
@@ -287,14 +367,14 @@ export class TerminalTransport {
if (previous && message.q > previous.sequence) {
if (message.t === 'output') this.projections.set(message.s, { ...previous, sequence: message.q, history: trimProjection(previous.history + (typeof message.r === 'string' ? message.r : (typeof message.d === 'string' ? message.d : ''))) });
else if (message.t === 'exit') this.projections.set(message.s, { ...previous, sequence: message.q, status: 'exited', exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null });
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: typeof message.history === 'string' ? message.history : '', status: 'running', exitCode: undefined, signal: null });
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: message.history ?? '', status: 'running', mode: parseTerminalMode(message.mode) ?? previous.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous.purpose, exitCode: undefined, signal: null });
}
for (const sub of subscribers) {
if (message.q <= sub.lastSequence) continue;
sub.lastSequence = message.q;
if (message.t === 'output') sub.handlers.onEvent({ type: 'data', sequence: message.q, data: typeof message.d === 'string' ? message.d : '', replayData: typeof message.r === 'string' ? message.r : undefined });
else if (message.t === 'exit') sub.handlers.onEvent({ type: 'exit', sequence: message.q, exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null });
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: typeof message.history === 'string' ? message.history : '', status: 'running' });
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: message.history ?? '', status: 'running', mode: parseTerminalMode(message.mode) ?? previous?.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous?.purpose });
}
}
@@ -354,27 +434,23 @@ let transport = new TerminalTransport();
export async function createTerminalSession(options: CreateTerminalOptions): Promise<TerminalSession> {
const response = await runtimeFetch('/api/terminal/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options) });
if (!response.ok) throw await responseError(response, 'Failed to create terminal session');
return response.json() as Promise<TerminalSession>;
const payload: unknown = await response.json().catch(() => null);
const parsed = terminalSessionSchema.safeParse(payload).data;
if (!parsed) throw new Error('Failed to create terminal session');
return parsed;
}
export async function listTerminalSessions(cwd: string): Promise<TerminalServerSession[]> {
const response = await runtimeFetch(`/api/terminal/sessions?cwd=${encodeURIComponent(cwd)}`);
if (!response.ok) throw await responseError(response, 'Failed to list terminal sessions');
const payload: unknown = await response.json().catch(() => null);
const rawSessions = payload && typeof payload === 'object' && 'sessions' in payload ? payload.sessions : null;
const rawSessions = terminalSessionListSchema.safeParse(payload).data?.sessions;
if (!Array.isArray(rawSessions)) throw new Error('Failed to list terminal sessions');
const parsed: TerminalServerSession[] = [];
for (const entry of rawSessions as unknown[]) {
if (typeof entry !== 'object' || entry === null) continue;
// SAFETY: every field is verified below before the value is used.
const candidate = entry as Partial<Record<keyof TerminalServerSession, unknown>>;
if (typeof candidate.sessionId !== 'string' || typeof candidate.cwd !== 'string') continue;
if (candidate.status !== 'running' && candidate.status !== 'exited') continue;
parsed.push({
sessionId: candidate.sessionId,
cwd: candidate.cwd,
status: candidate.status,
createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : null,
});
for (const entry of rawSessions) {
const session = terminalServerSessionSchema.safeParse(entry).data;
if (session) {
parsed.push(session);
}
}
return parsed;
}
+4
View File
@@ -124,6 +124,10 @@ Invariants to preserve when editing:
projection while both are referentially unchanged, and the storage adapter skips a write
for an unchanged projection, so streaming output performs no persistence work.
- Consumers that react to output must subscribe to `buffers`, not `sessions`.
- Server session listings capture the directory's per-action mutation revisions when the
request starts. Coalesced callers share that first snapshot. A response cannot replace or
remove an action execution mutated after its request began, while a fresh successful empty
response still clears an omitted run.
## Git / PR Stores
@@ -131,6 +131,7 @@ const deferred = <T,>() => {
mock.module('@/stores/utils/safeStorage', () => ({
getDeferredSafeStorage: () => makeStorage(),
getSafeStorage: () => makeStorage(),
getSafeSessionStorage: () => makeStorage(),
createDeferredSafeJSONStorage: () => {
const testStorage = makeStorage();
return {
+371 -14
View File
@@ -1,5 +1,8 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { useTerminalStore } from './useTerminalStore';
import type { TerminalServerSession } from '@/lib/api/types';
import { directoryMayHaveActiveProjectAction, useTerminalStore } from './useTerminalStore';
const setup = () => {
useTerminalStore.getState().clearAll();
@@ -9,14 +12,35 @@ const setup = () => {
const buffer = (tabId: string) => useTerminalStore.getState().getBuffer('/repo', tabId);
const staleBuildSession: TerminalServerSession = {
sessionId: 'srv-old',
cwd: '/repo',
status: 'running',
createdAt: 1,
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-old' },
};
const captureStartedActionMutationRevisions = (directory: string) => {
return useTerminalStore.getState().captureStartedActionMutationRevisions(directory);
};
const reconcileServerSessionsWithStartedRevisions = (
directory: string,
serverSessions: TerminalServerSession[],
startedActionMutationRevisions: ReadonlyMap<string, number>,
) => {
useTerminalStore.getState().reconcileServerSessions(directory, serverSessions, { startedActionMutationRevisions });
};
describe('terminal state reconciliation', () => {
afterEach(() => useTerminalStore.getState().clearAll());
test('adopts unknown server sessions into the fresh placeholder tab', () => {
test('reconciles unknown server sessions into the fresh placeholder tab', () => {
setup();
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
{ sessionId: 'srv-2', status: 'exited', createdAt: null },
useTerminalStore.getState().reconcileServerSessions('/repo', [
{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 100 },
{ sessionId: 'srv-2', cwd: '/repo', status: 'exited', createdAt: null },
]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.tabs.map((tab) => tab.id)).toEqual(['srv-1', 'srv-2']);
@@ -26,13 +50,13 @@ describe('terminal state reconciliation', () => {
expect(state.activeTabId).toBe('srv-1');
});
test('adoption is additive: existing tabs and referenced sessions survive', () => {
test('reconciliation keeps existing interactive tabs and adopts unknown sessions', () => {
const tabId = setup();
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'output', 1);
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-live');
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-live', status: 'running', createdAt: 1 },
{ sessionId: 'srv-orphan', status: 'running', createdAt: 2 },
useTerminalStore.getState().reconcileServerSessions('/repo', [
{ sessionId: 'srv-live', cwd: '/repo', status: 'running', createdAt: 1 },
{ sessionId: 'srv-orphan', cwd: '/repo', status: 'running', createdAt: 2 },
]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.tabs).toHaveLength(2);
@@ -41,18 +65,327 @@ describe('terminal state reconciliation', () => {
expect(state.activeTabId).toBe(tabId);
});
test('re-adopting the same sessions changes nothing', () => {
test('re-reconciling the same sessions changes nothing', () => {
setup();
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
useTerminalStore.getState().reconcileServerSessions('/repo', [
{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 100 },
]);
const before = useTerminalStore.getState().sessions;
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
useTerminalStore.getState().reconcileServerSessions('/repo', [
{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 100 },
]);
expect(useTerminalStore.getState().sessions).toBe(before);
});
test('successful empty reconciliation with no local directory keeps the original sessions reference', () => {
const before = useTerminalStore.getState().sessions;
useTerminalStore.getState().reconcileServerSessions('/missing', []);
expect(useTerminalStore.getState().sessions).toBe(before);
});
test('hydrates persisted action tabs with live fields reset until server authority returns', () => {
const mergePersistedState = useTerminalStore.persist.getOptions().merge;
if (!mergePersistedState) {
throw new Error('expected persisted merge helper');
}
const hydrated = mergePersistedState({
sessions: [[
'/repo',
{
activeTabId: 'tab-a',
tabs: [{ id: 'tab-a', label: 'Build', iconKey: 'build', createdAt: 10, purpose: { type: 'project-action', actionId: 'build' } }],
},
]],
nextTabId: 2,
}, useTerminalStore.getState());
const tab = hydrated.sessions.get('/repo')?.tabs[0];
expect(tab?.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
expect(tab?.lifecycle).toBe('idle');
expect(tab?.terminalSessionId).toBeNull();
});
test('adopts an existing running project-action session by action id across clients', () => {
const tabId = setup();
useTerminalStore.getState().setTabPurpose('/repo', tabId, { type: 'project-action', actionId: 'build', executionId: null });
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'srv-shared',
cwd: '/repo',
status: 'running',
createdAt: 1,
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-2' },
}]);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.id).toBe(tabId);
expect(tab.terminalSessionId).toBe('srv-shared');
expect(tab.lifecycle).toBe('running');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: 'exec-2' });
});
test('activates a rebound running project-action tab when the current active tab is idle and sessionless', () => {
const actionTabId = setup();
const shellTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabPurpose('/repo', actionTabId, { type: 'project-action', actionId: 'build', executionId: null });
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'srv-build',
cwd: '/repo',
status: 'running',
createdAt: 10,
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.activeTabId).toBe(actionTabId);
});
test('does not activate a rebound running project-action tab when the current active tab is a live running terminal', () => {
const actionTabId = setup();
const shellTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabPurpose('/repo', actionTabId, { type: 'project-action', actionId: 'build', executionId: null });
useTerminalStore.getState().setTabSessionId('/repo', shellTabId, 'srv-shell');
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'srv-build',
cwd: '/repo',
status: 'running',
createdAt: 10,
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.activeTabId).toBe(shellTabId);
});
test('does not activate an exited adopted project-action tab', () => {
const actionTabId = setup();
const shellTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabPurpose('/repo', actionTabId, { type: 'project-action', actionId: 'build', executionId: null });
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'srv-build',
cwd: '/repo',
status: 'exited',
createdAt: 10,
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.activeTabId).toBe(shellTabId);
});
test('activates the newest adopted running project action when multiple qualify in one reconciliation', () => {
const firstActionTabId = setup();
const secondActionTabId = useTerminalStore.getState().createTab('/repo');
const shellTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabPurpose('/repo', firstActionTabId, { type: 'project-action', actionId: 'build', executionId: null });
useTerminalStore.getState().setTabPurpose('/repo', secondActionTabId, { type: 'project-action', actionId: 'test', executionId: null });
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
useTerminalStore.getState().reconcileServerSessions('/repo', [
{
sessionId: 'srv-build',
cwd: '/repo',
status: 'running',
createdAt: 10,
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
{
sessionId: 'srv-test',
cwd: '/repo',
status: 'running',
createdAt: 20,
mode: 'command',
purpose: { type: 'project-action', actionId: 'test', executionId: 'exec-2' },
},
]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.activeTabId).toBe(secondActionTabId);
});
// Activation transitions always rewrite the affected tab (session id or
// lifecycle changes), so there is no reachable activation without a tab
// update; this pins the in-place rebind case where only one tab mutates
// and no tab is added, removed, or reordered.
test('activates an in-place running rebind of an existing action tab without structural tab changes', () => {
const actionTabId = setup();
const shellTabId = useTerminalStore.getState().createTab('/repo');
useTerminalStore.getState().setTabPurpose('/repo', actionTabId, { type: 'project-action', actionId: 'build', executionId: 'exec-1' });
useTerminalStore.getState().setTabSessionId('/repo', actionTabId, 'srv-build', { expectedExecutionId: 'exec-1' });
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
useTerminalStore.getState().setTabLifecycle('/repo', actionTabId, 'idle', { expectedExecutionId: 'exec-1' });
const beforeTabs = useTerminalStore.getState().getDirectoryState('/repo')!.tabs;
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'srv-build',
cwd: '/repo',
status: 'running',
createdAt: beforeTabs[0]!.createdAt,
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.tabs).not.toBe(beforeTabs);
expect(state.activeTabId).toBe(actionTabId);
});
test('gives unknown adopted action sessions a generic fallback label and icon', () => {
setup();
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'srv-build',
cwd: '/repo',
status: 'running',
createdAt: 1,
mode: 'command',
purpose: { type: 'project-action', actionId: 'unknown-action', executionId: 'exec-1' },
}]);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.label).toBe('unknown-action');
expect(tab.iconKey).toBe('play');
});
test('successful empty reconciliation exits known action sessions without touching unrelated directories', () => {
const repoTab = setup();
useTerminalStore.getState().setTabPurpose('/repo', repoTab, { type: 'project-action', actionId: 'build', executionId: 'exec-1' });
useTerminalStore.getState().setTabSessionId('/repo', repoTab, 'srv-build');
useTerminalStore.getState().ensureDirectory('/other');
const otherBefore = useTerminalStore.getState().getDirectoryState('/other');
useTerminalStore.getState().reconcileServerSessions('/repo', []);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.lifecycle).toBe('exited');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
expect(useTerminalStore.getState().getDirectoryState('/other')).toBe(otherBefore);
});
test('normalizes executionId to null when adopting an exited project-action session', () => {
const tabId = setup();
useTerminalStore.getState().setTabPurpose('/repo', tabId, { type: 'project-action', actionId: 'build', executionId: null });
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'srv-build',
cwd: '/repo',
status: 'exited',
createdAt: 10,
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-server' },
}]);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
});
test('an old empty snapshot preserves a newer starting action execution', () => {
const tabId = setup();
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
expect(executionId).not.toBeNull();
reconcileServerSessionsWithStartedRevisions('/repo', [], startedActionMutationRevisions);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.lifecycle).toBe('starting');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
expect(tab.terminalSessionId).toBeNull();
});
test('an old empty snapshot still preserves the action after it becomes running before apply', () => {
const tabId = setup();
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
if (!executionId) {
throw new Error('expected execution id');
}
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-build', { expectedExecutionId: executionId });
reconcileServerSessionsWithStartedRevisions('/repo', [], startedActionMutationRevisions);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.lifecycle).toBe('running');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
expect(tab.terminalSessionId).toBe('srv-build');
});
test('an old listed action session does not overwrite a newer execution after allocate', () => {
const tabId = setup();
useTerminalStore.getState().setTabPurpose('/repo', tabId, { type: 'project-action', actionId: 'build', executionId: 'exec-old' });
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-old', { expectedExecutionId: 'exec-old' });
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
reconcileServerSessionsWithStartedRevisions('/repo', [staleBuildSession], startedActionMutationRevisions);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.lifecycle).toBe('starting');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
expect(tab.terminalSessionId).toBe('srv-old');
});
test('an old listed action session does not overwrite a newer running session after setTabSessionId', () => {
const tabId = setup();
useTerminalStore.getState().setTabPurpose('/repo', tabId, { type: 'project-action', actionId: 'build', executionId: 'exec-old' });
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-old', { expectedExecutionId: 'exec-old' });
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
if (!executionId) {
throw new Error('expected execution id');
}
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-new', { expectedExecutionId: executionId });
reconcileServerSessionsWithStartedRevisions('/repo', [staleBuildSession], startedActionMutationRevisions);
const tabs = useTerminalStore.getState().getDirectoryState('/repo')!.tabs;
expect(tabs).toHaveLength(1);
const tab = tabs[0]!;
expect(tab.lifecycle).toBe('running');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
expect(tab.terminalSessionId).toBe('srv-new');
});
test('a fresh empty snapshot still clears an omitted action execution', () => {
const tabId = setup();
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
if (!executionId) {
throw new Error('expected execution id');
}
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-build', { expectedExecutionId: executionId });
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
reconcileServerSessionsWithStartedRevisions('/repo', [], startedActionMutationRevisions);
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.lifecycle).toBe('exited');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
expect(tab.terminalSessionId).toBe('srv-build');
});
test('execution-guarded transitions ignore stale completions after a rerun', () => {
const tabId = setup();
const firstExecution = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build')!;
const secondExecution = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build')!;
expect(firstExecution).not.toBe(secondExecution);
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-old', { expectedExecutionId: firstExecution });
useTerminalStore.getState().setTabLifecycle('/repo', tabId, 'running', { expectedExecutionId: secondExecution });
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.terminalSessionId).toBeNull();
expect(tab.lifecycle).toBe('running');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: secondExecution });
});
test('applies snapshots atomically and deduplicates output by sequence', () => {
const tabId = setup();
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
@@ -199,3 +532,27 @@ describe('default terminal tab labels', () => {
expect(labels()).toEqual(['build', 'Terminal']);
});
});
describe('directoryMayHaveActiveProjectAction', () => {
test('returns true for any non-exited project-action tab', () => {
expect(directoryMayHaveActiveProjectAction({
activeTabId: 'tab-1',
tabs: [
{
id: 'tab-1',
terminalSessionId: null,
lifecycle: 'idle',
purpose: { type: 'project-action', actionId: 'build', executionId: null },
label: 'Build',
iconKey: 'play',
isConnecting: false,
createdAt: 1,
previewUrl: null,
previewAutoOpened: false,
previewUrlLocked: false,
},
],
})).toBe(true);
expect(directoryMayHaveActiveProjectAction(undefined)).toBe(false);
});
});
+387 -110
View File
@@ -1,8 +1,10 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import type { PersistStorage } from 'zustand/middleware';
import { z } from 'zod';
import { getSafeSessionStorage } from '@/stores/utils/safeStorage';
import type { TerminalServerSession } from '@/lib/api/types';
export interface TerminalChunk {
id: number;
@@ -28,12 +30,23 @@ export const EMPTY_TERMINAL_BUFFER: TerminalBuffer = Object.freeze({
lastSequence: -1,
});
export type TerminalTabLifecycle = 'idle' | 'running' | 'exited';
export type TerminalTabLifecycle = 'idle' | 'starting' | 'running' | 'stopping' | 'exited';
export const ACTIVE_PROJECT_ACTION_LIFECYCLES: ReadonlySet<TerminalTabLifecycle> = new Set([
'starting',
'running',
'stopping',
]);
export type TerminalTabPurpose =
| { type: 'terminal' }
| { type: 'project-action'; actionId: string; executionId: string | null };
export type TerminalTab = {
id: string;
terminalSessionId: string | null;
lifecycle: TerminalTabLifecycle;
purpose: TerminalTabPurpose;
label: string;
iconKey: string | null;
isConnecting: boolean;
@@ -48,19 +61,20 @@ export type DirectoryTerminalState = {
activeTabId: string | null;
};
export type TerminalProjectActionRun = {
key: string;
directory: string;
actionId: string;
tabId: string;
sessionId: string;
status: 'running' | 'waiting-for-preview' | 'stopping';
export const directoryMayHaveActiveProjectAction = (state: DirectoryTerminalState | undefined): boolean =>
Boolean(state?.tabs.some((tab) => isProjectActionPurpose(tab.purpose) && tab.lifecycle !== 'exited'));
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
type ReconcileServerSessionsOptions = {
startedActionMutationRevisions?: TerminalActionMutationRevisions;
};
interface TerminalStore {
sessions: Map<string, DirectoryTerminalState>;
buffers: Map<string, TerminalBuffer>;
projectActionRuns: Record<string, TerminalProjectActionRun>;
actionMutationRevisions: Map<string, number>;
nextActionMutationRevision: number;
nextChunkId: number;
nextTabId: number;
hasHydrated: boolean;
@@ -69,27 +83,25 @@ interface TerminalStore {
getDirectoryState: (directory: string) => DirectoryTerminalState | undefined;
getActiveTab: (directory: string) => TerminalTab | undefined;
getBuffer: (directory: string, tabId: string) => TerminalBuffer;
matchesActionExecution: (directory: string, tabId: string, executionId: string | null | undefined) => boolean;
captureStartedActionMutationRevisions: (directory: string) => TerminalActionMutationRevisions;
createTab: (directory: string) => string;
adoptServerSessions: (
directory: string,
serverSessions: Array<{ sessionId: string; status: 'running' | 'exited'; createdAt: number | null }>,
) => void;
reconcileServerSessions: (directory: string, serverSessions: TerminalServerSession[], options?: ReconcileServerSessionsOptions) => void;
setActiveTab: (directory: string, tabId: string) => void;
setTabLabel: (directory: string, tabId: string, label: string) => void;
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
closeTab: (directory: string, tabId: string) => void;
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => void;
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
setTabPurpose: (directory: string, tabId: string, purpose: TerminalTabPurpose) => void;
allocateActionExecution: (directory: string, tabId: string, actionId: string) => string | null;
setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options?: { expectedExecutionId?: string | null }) => void;
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options?: { expectedExecutionId?: string | null }) => void;
setConnecting: (directory: string, tabId: string, isConnecting: boolean, options?: { expectedExecutionId?: string | null }) => void;
replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => void;
appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => void;
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean }) => void;
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean; expectedExecutionId?: string | null }) => void;
markPreviewAutoOpened: (directory: string, tabId: string) => void;
setProjectActionRun: (run: TerminalProjectActionRun) => void;
updateProjectActionRunStatus: (runKey: string, status: TerminalProjectActionRun['status']) => void;
removeProjectActionRun: (runKey: string) => void;
removeDirectory: (directory: string) => void;
clearAll: () => void;
@@ -124,6 +136,10 @@ const dropBufferKeys = (
const TERMINAL_STORE_NAME = 'terminal-store';
let hydrationListenerAttached = false;
let fallbackTabId = 0;
const persistedProjectActionPurposeSchema = z.object({
type: z.literal('project-action'),
actionId: z.string(),
});
const createTerminalTabId = (): string => {
if (typeof globalThis.crypto?.randomUUID === 'function') return `tab-${globalThis.crypto.randomUUID()}`;
@@ -131,7 +147,13 @@ const createTerminalTabId = (): string => {
return `tab-${Date.now().toString(36)}-${fallbackTabId.toString(36)}`;
};
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'createdAt'>;
type PersistedTerminalTabPurpose =
| { type: 'terminal' }
| { type: 'project-action'; actionId: string };
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'createdAt'> & {
purpose?: PersistedTerminalTabPurpose;
};
type PersistedDirectoryTerminalState = {
tabs: PersistedTerminalTab[];
@@ -146,6 +168,38 @@ type PersistedTerminalStoreState = {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const isProjectActionPurpose = (purpose: TerminalTabPurpose): purpose is Extract<TerminalTabPurpose, { type: 'project-action' }> =>
purpose.type === 'project-action';
const shouldApplyExecutionGuard = (tab: TerminalTab, expectedExecutionId: string | null | undefined): boolean => {
if (!isProjectActionPurpose(tab.purpose)) {
return expectedExecutionId == null;
}
if (expectedExecutionId === undefined) {
return true;
}
return tab.purpose.executionId === expectedExecutionId;
};
const createExecutionId = (): string => createTerminalTabId().replace(/^tab-/, 'exec-');
const toActionLifecycle = (status: TerminalServerSession['status']): TerminalTabLifecycle =>
status === 'running' ? 'running' : 'exited';
const toActionPurposeFromSession = (
purpose: Extract<TerminalServerSession['purpose'], { type: 'project-action' }>,
status: TerminalServerSession['status'],
): Extract<TerminalTabPurpose, { type: 'project-action' }> => ({
type: 'project-action',
actionId: purpose.actionId,
executionId: status === 'running' ? purpose.executionId : null,
});
const toAdoptedActionLabel = (actionId: string): string => {
const trimmed = actionId.trim();
return trimmed || 'Action';
};
const tabIdNumber = (tabId: string): number | null => {
const match = /^tab-(\d+)$/.exec(tabId);
if (!match) return null;
@@ -161,6 +215,32 @@ function normalizeDirectory(dir: string): string {
return normalized;
}
const actionMutationRevisionKey = (directory: string, actionId: string): string => `${directory}\u0000${actionId}`;
const updateActionMutationRevision = (
revisions: Map<string, number>,
directory: string,
actionId: string,
revision: number,
) => {
revisions.set(actionMutationRevisionKey(directory, actionId), revision);
};
const hasActionMutatedSinceRequestStarted = (
state: Pick<TerminalStore, 'actionMutationRevisions'>,
directory: string,
actionId: string,
startedActionMutationRevisions: TerminalActionMutationRevisions | undefined,
): boolean => {
if (!startedActionMutationRevisions) {
return false;
}
const revisionKey = actionMutationRevisionKey(directory, actionId);
const currentRevision = state.actionMutationRevisions.get(revisionKey) ?? 0;
const startedRevision = startedActionMutationRevisions.get(revisionKey) ?? 0;
return currentRevision > startedRevision;
};
const DEFAULT_TAB_LABEL_PATTERN = /^Terminal(?: (\d+))?$/;
/**
@@ -182,10 +262,14 @@ const nextDefaultTabLabel = (tabs: readonly TerminalTab[]): string => {
return highest === 0 ? 'Terminal' : `Terminal ${highest + 1}`;
};
const isLiveRunningTerminal = (tab: TerminalTab | undefined): boolean =>
Boolean(tab && tab.terminalSessionId !== null && tab.lifecycle === 'running');
const createEmptyTab = (id: string, label: string): TerminalTab => ({
id,
terminalSessionId: null,
lifecycle: 'idle',
purpose: { type: 'terminal' },
label,
iconKey: null,
isConnecting: false,
@@ -231,6 +315,9 @@ const partializeTerminalStore = (state: TerminalStore): PersistedTerminalStoreSt
label: tab.label,
iconKey: tab.iconKey,
createdAt: tab.createdAt,
purpose: tab.purpose.type === 'project-action'
? { type: 'project-action', actionId: tab.purpose.actionId }
: { type: 'terminal' },
})),
},
]),
@@ -267,7 +354,8 @@ export const useTerminalStore = create<TerminalStore>()(
(set, get) => ({
sessions: new Map(),
buffers: new Map(),
projectActionRuns: {},
actionMutationRevisions: new Map(),
nextActionMutationRevision: 1,
nextChunkId: 1,
nextTabId: 1,
hasHydrated: typeof window === 'undefined',
@@ -307,6 +395,22 @@ export const useTerminalStore = create<TerminalStore>()(
getBuffer: (directory: string, tabId: string) =>
get().buffers.get(bufferKey(normalizeDirectory(directory), tabId)) ?? EMPTY_TERMINAL_BUFFER,
matchesActionExecution: (directory, tabId, executionId) => {
const tab = get().sessions.get(normalizeDirectory(directory))?.tabs.find((entry) => entry.id === tabId);
return Boolean(tab && isProjectActionPurpose(tab.purpose) && tab.purpose.executionId === executionId);
},
captureStartedActionMutationRevisions: (directory) => {
const key = normalizeDirectory(directory);
const prefix = actionMutationRevisionKey(key, '');
const snapshot = new Map<string, number>();
for (const [actionKey, revision] of get().actionMutationRevisions.entries()) {
if (!actionKey.startsWith(prefix)) continue;
snapshot.set(actionKey, revision);
}
return snapshot;
},
createTab: (directory: string) => {
const key = normalizeDirectory(directory);
if (!key) {
@@ -338,56 +442,171 @@ export const useTerminalStore = create<TerminalStore>()(
return tabId;
},
/**
* The server owns which terminal sessions exist; the local tab list is
* only this client's projection. Adoption is strictly additive: server
* sessions no local tab references become tabs (id = session id, the
* create/attach contract), and nothing is ever removed here, so a
* failed or partial listing cannot destroy local tabs.
*/
adoptServerSessions: (directory, serverSessions) => {
reconcileServerSessions: (directory, serverSessions, options) => {
const key = normalizeDirectory(directory);
if (!key || serverSessions.length === 0) return;
if (!key) return;
set((state) => {
const existing = state.sessions.get(key);
const knownIds = new Set<string>();
for (const tab of existing?.tabs ?? []) {
knownIds.add(tab.id);
if (tab.terminalSessionId) knownIds.add(tab.terminalSessionId);
if (!existing && serverSessions.length === 0) {
return state;
}
const newcomers = serverSessions.filter((session) => !knownIds.has(session.sessionId));
if (newcomers.length === 0) return state;
const tabs = [...(existing?.tabs ?? [])];
// A single untouched placeholder tab (fresh directory state) is
// replaced by the first adopted session instead of sitting next to it.
let tabsChanged = false;
let activationCandidate: { tabId: string; createdAt: number; index: number } | null = null;
const listedActionIds = new Set<string>();
const matchedTabIds = new Set<string>();
const placeholder = tabs.length === 1
&& tabs[0].terminalSessionId === null
&& tabs[0].lifecycle === 'idle'
&& tabs[0].purpose.type === 'terminal'
&& !state.buffers.has(bufferKey(key, tabs[0].id))
? tabs[0]
: null;
if (placeholder) tabs.length = 0;
if (placeholder && serverSessions.length > 0) tabs.length = 0;
for (const session of newcomers) {
for (const session of serverSessions) {
let matchIndex = tabs.findIndex((tab) => tab.terminalSessionId === session.sessionId || tab.id === session.sessionId);
const sessionPurpose = session.purpose;
const staleActionAuthority = sessionPurpose?.type === 'project-action'
&& hasActionMutatedSinceRequestStarted(state, key, sessionPurpose.actionId, options?.startedActionMutationRevisions);
if (sessionPurpose?.type === 'project-action') {
listedActionIds.add(sessionPurpose.actionId);
const actionMatchIndex = tabs.findIndex((tab) => (
isProjectActionPurpose(tab.purpose) && tab.purpose.actionId === sessionPurpose.actionId
));
if (staleActionAuthority) {
if (actionMatchIndex >= 0) {
matchedTabIds.add(tabs[actionMatchIndex]!.id);
}
continue;
}
if (matchIndex < 0) {
matchIndex = actionMatchIndex;
}
}
const nextPurpose: TerminalTabPurpose = sessionPurpose?.type === 'project-action'
? toActionPurposeFromSession(sessionPurpose, session.status)
: { type: 'terminal' };
if (matchIndex >= 0) {
const current = tabs[matchIndex]!;
const nextLifecycle = current.purpose.type === 'project-action' || sessionPurpose?.type === 'project-action'
? toActionLifecycle(session.status)
: session.status;
const nextCreatedAt = session.createdAt ?? current.createdAt;
const activatesRunningAction = sessionPurpose?.type === 'project-action'
&& session.status === 'running'
&& (current.terminalSessionId !== session.sessionId || current.lifecycle !== 'running');
const purposeChanged = current.purpose.type !== nextPurpose.type
|| (current.purpose.type === 'project-action' && nextPurpose.type === 'project-action'
&& (current.purpose.actionId !== nextPurpose.actionId || current.purpose.executionId !== nextPurpose.executionId));
if (
current.terminalSessionId !== session.sessionId
|| current.lifecycle !== nextLifecycle
|| current.isConnecting !== false
|| current.createdAt !== nextCreatedAt
|| purposeChanged
) {
tabs[matchIndex] = {
...current,
terminalSessionId: session.sessionId,
lifecycle: nextLifecycle,
purpose: nextPurpose,
isConnecting: false,
createdAt: nextCreatedAt,
};
tabsChanged = true;
}
if (activatesRunningAction) {
const candidate = { tabId: current.id, createdAt: nextCreatedAt, index: matchIndex };
if (
!activationCandidate
|| candidate.createdAt > activationCandidate.createdAt
|| (candidate.createdAt === activationCandidate.createdAt && candidate.index > activationCandidate.index)
) {
activationCandidate = candidate;
}
}
matchedTabIds.add(current.id);
continue;
}
const label = sessionPurpose?.type === 'project-action'
? toAdoptedActionLabel(sessionPurpose.actionId)
: (placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs));
const iconKey = sessionPurpose?.type === 'project-action' ? 'play' : null;
const tab: TerminalTab = {
...createEmptyTab(session.sessionId, placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs)),
...createEmptyTab(session.sessionId, label),
terminalSessionId: session.sessionId,
lifecycle: session.status,
lifecycle: sessionPurpose?.type === 'project-action' ? toActionLifecycle(session.status) : session.status,
purpose: nextPurpose,
iconKey,
createdAt: session.createdAt ?? Date.now(),
};
tabs.push(tab);
tabsChanged = true;
if (sessionPurpose?.type === 'project-action' && session.status === 'running') {
const candidate = { tabId: tab.id, createdAt: tab.createdAt, index: tabs.length - 1 };
if (
!activationCandidate
|| candidate.createdAt > activationCandidate.createdAt
|| (candidate.createdAt === activationCandidate.createdAt && candidate.index > activationCandidate.index)
) {
activationCandidate = candidate;
}
}
matchedTabIds.add(tab.id);
}
let changed = tabsChanged || Boolean(placeholder && serverSessions.length > 0);
const reconciledTabs: TerminalTab[] = tabs.map((tab) => {
if (!isProjectActionPurpose(tab.purpose)) return tab;
if (listedActionIds.has(tab.purpose.actionId) || matchedTabIds.has(tab.id)) return tab;
if (
tab.lifecycle === 'starting'
|| hasActionMutatedSinceRequestStarted(state, key, tab.purpose.actionId, options?.startedActionMutationRevisions)
) {
return tab;
}
if (tab.lifecycle === 'exited' && !tab.isConnecting) return tab;
changed = true;
return {
...tab,
lifecycle: 'exited',
isConnecting: false,
purpose: { type: 'project-action', actionId: tab.purpose.actionId, executionId: null },
};
});
const previousActive = existing?.activeTabId ?? null;
const activeTabId = previousActive && tabs.some((tab) => tab.id === previousActive)
const resolvedActiveTabId = previousActive && reconciledTabs.some((tab) => tab.id === previousActive)
? previousActive
: tabs[0]?.id ?? null;
: reconciledTabs[0]?.id ?? null;
const resolvedActiveTab = resolvedActiveTabId
? reconciledTabs.find((tab) => tab.id === resolvedActiveTabId)
: reconciledTabs[0];
const activeTabId = activationCandidate && !isLiveRunningTerminal(resolvedActiveTab)
? activationCandidate.tabId
: resolvedActiveTabId;
// Activation transitions always rewrite the adopted tab, so today
// `changed` is true whenever `activeTabId` moved; the explicit
// activeTabId comparison keeps this guard honest if a future
// activation path stops touching the tabs array.
if (
!changed
&& existing
&& activeTabId === existing.activeTabId
&& reconciledTabs.length === existing.tabs.length
&& reconciledTabs.every((tab, index) => tab === existing.tabs[index])
) {
return state;
}
const newSessions = new Map(state.sessions);
newSessions.set(key, { tabs, activeTabId });
newSessions.set(key, { tabs: reconciledTabs, activeTabId });
return { sessions: newSessions };
});
},
@@ -497,10 +716,6 @@ export const useTerminalStore = create<TerminalStore>()(
}
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
const nextRuns = Object.fromEntries(
Object.entries(state.projectActionRuns).filter(([, run]) => !(run.directory === key && run.tabId === tabId))
);
const runsChanged = Object.keys(nextRuns).length !== Object.keys(state.projectActionRuns).length;
const closedBufferKey = bufferKey(key, tabId);
const nextBuffers = state.buffers.has(closedBufferKey)
? dropBufferKeys(state.buffers, (bufferEntryKey) => bufferEntryKey === closedBufferKey)
@@ -510,12 +725,14 @@ export const useTerminalStore = create<TerminalStore>()(
const newTabId = createTerminalTabId();
const newTab = createEmptyTab(newTabId, 'Terminal');
newSessions.set(key, createEmptyDirectoryState(newTab));
return {
const nextState: Pick<TerminalStore, 'sessions' | 'nextTabId'> & { buffers?: Map<string, TerminalBuffer> } = {
sessions: newSessions,
nextTabId: state.nextTabId + 1,
...(nextBuffers ? { buffers: nextBuffers } : {}),
...(runsChanged ? { projectActionRuns: nextRuns } : {}),
};
if (nextBuffers) {
nextState.buffers = nextBuffers;
}
return nextState;
}
let nextActive = existing.activeTabId;
@@ -530,15 +747,67 @@ export const useTerminalStore = create<TerminalStore>()(
activeTabId: nextActive,
});
return {
const nextState: Pick<TerminalStore, 'sessions'> & { buffers?: Map<string, TerminalBuffer> } = {
sessions: newSessions,
...(nextBuffers ? { buffers: nextBuffers } : {}),
...(runsChanged ? { projectActionRuns: nextRuns } : {}),
};
if (nextBuffers) {
nextState.buffers = nextBuffers;
}
return nextState;
});
},
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => {
setTabPurpose: (directory, tabId, purpose) => {
const key = normalizeDirectory(directory);
set((state) => {
const existing = state.sessions.get(key);
if (!existing) return state;
const idx = findTabIndex(existing, tabId);
if (idx < 0) return state;
const current = existing.tabs[idx]!;
if (JSON.stringify(current.purpose) === JSON.stringify(purpose)) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...current, purpose };
const sessions = new Map(state.sessions);
sessions.set(key, { ...existing, tabs: nextTabs });
if (purpose.type !== 'project-action') {
return { sessions };
}
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, purpose.actionId, state.nextActionMutationRevision);
return { sessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
});
},
allocateActionExecution: (directory, tabId, actionId) => {
const key = normalizeDirectory(directory);
const existing = get().sessions.get(key);
if (!existing || findTabIndex(existing, tabId) < 0) return null;
const executionId = createExecutionId();
set((state) => {
const current = state.sessions.get(key);
if (!current) return state;
const idx = findTabIndex(current, tabId);
if (idx < 0) return state;
const nextTabs = [...current.tabs];
nextTabs[idx] = {
...nextTabs[idx]!,
purpose: { type: 'project-action', actionId, executionId },
lifecycle: 'starting',
isConnecting: false,
};
const sessions = new Map(state.sessions);
sessions.set(key, { ...current, tabs: nextTabs });
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, actionId, state.nextActionMutationRevision);
return { sessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
});
return executionId;
},
setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
@@ -553,6 +822,9 @@ export const useTerminalStore = create<TerminalStore>()(
}
const tab = existing.tabs[idx];
if (!shouldApplyExecutionGuard(tab, options?.expectedExecutionId)) {
return state;
}
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
const nextLifecycle = sessionId
@@ -574,11 +846,27 @@ export const useTerminalStore = create<TerminalStore>()(
const nextTabs = [...existing.tabs];
nextTabs[idx] = nextTab;
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions, ...(nextBuffers ? { buffers: nextBuffers } : {}) };
const nextState: Pick<TerminalStore, 'sessions' | 'nextActionMutationRevision'> & {
buffers?: Map<string, TerminalBuffer>;
actionMutationRevisions?: Map<string, number>;
} = {
sessions: newSessions,
nextActionMutationRevision: state.nextActionMutationRevision,
};
if (isProjectActionPurpose(tab.purpose)) {
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, tab.purpose.actionId, state.nextActionMutationRevision);
nextState.actionMutationRevisions = actionMutationRevisions;
nextState.nextActionMutationRevision = state.nextActionMutationRevision + 1;
}
if (nextBuffers) {
nextState.buffers = nextBuffers;
}
return nextState;
});
},
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => {
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
@@ -592,6 +880,10 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
if (!shouldApplyExecutionGuard(existing.tabs[idx]!, options?.expectedExecutionId)) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...nextTabs[idx], lifecycle, isConnecting: false };
newSessions.set(key, { ...existing, tabs: nextTabs });
@@ -599,7 +891,7 @@ export const useTerminalStore = create<TerminalStore>()(
});
},
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => {
setConnecting: (directory: string, tabId: string, isConnecting: boolean, options) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
@@ -613,6 +905,10 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
if (!shouldApplyExecutionGuard(existing.tabs[idx]!, options?.expectedExecutionId)) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...nextTabs[idx], isConnecting };
newSessions.set(key, { ...existing, tabs: nextTabs });
@@ -711,6 +1007,9 @@ export const useTerminalStore = create<TerminalStore>()(
}
const tab = existing.tabs[idx];
if (!shouldApplyExecutionGuard(tab, options.expectedExecutionId)) {
return state;
}
const nextPreviewAutoOpened = options.autoOpened ?? tab.previewAutoOpened;
const nextPreviewUrlLocked = options.locked ?? tab.previewUrlLocked;
if (tab.previewUrl === url && tab.previewAutoOpened === nextPreviewAutoOpened && tab.previewUrlLocked === nextPreviewUrlLocked) {
@@ -755,47 +1054,6 @@ export const useTerminalStore = create<TerminalStore>()(
});
},
setProjectActionRun: (run: TerminalProjectActionRun) => {
set((state) => {
const existing = state.projectActionRuns[run.key];
if (existing
&& existing.directory === run.directory
&& existing.actionId === run.actionId
&& existing.tabId === run.tabId
&& existing.sessionId === run.sessionId
&& existing.status === run.status) {
return state;
}
return { projectActionRuns: { ...state.projectActionRuns, [run.key]: run } };
});
},
updateProjectActionRunStatus: (runKey: string, status: TerminalProjectActionRun['status']) => {
set((state) => {
const existing = state.projectActionRuns[runKey];
if (!existing || existing.status === status) {
return state;
}
return {
projectActionRuns: {
...state.projectActionRuns,
[runKey]: { ...existing, status },
},
};
});
},
removeProjectActionRun: (runKey: string) => {
set((state) => {
if (!state.projectActionRuns[runKey]) {
return state;
}
const next = { ...state.projectActionRuns };
delete next[runKey];
return { projectActionRuns: next };
});
},
removeDirectory: (directory: string) => {
const key = normalizeDirectory(directory);
set((state) => {
@@ -803,19 +1061,31 @@ export const useTerminalStore = create<TerminalStore>()(
newSessions.delete(key);
const prefix = bufferKey(key, '');
const nextBuffers = dropBufferKeys(state.buffers, (entryKey) => entryKey.startsWith(prefix));
const nextRuns = Object.fromEntries(
Object.entries(state.projectActionRuns).filter(([, run]) => run.directory !== key)
);
return {
const revisionPrefix = actionMutationRevisionKey(key, '');
let actionMutationRevisions: Map<string, number> | undefined;
for (const actionKey of state.actionMutationRevisions.keys()) {
if (!actionKey.startsWith(revisionPrefix)) continue;
actionMutationRevisions ??= new Map(state.actionMutationRevisions);
actionMutationRevisions.delete(actionKey);
}
const nextState: Pick<TerminalStore, 'sessions'> & {
buffers?: Map<string, TerminalBuffer>;
actionMutationRevisions?: Map<string, number>;
} = {
sessions: newSessions,
...(nextBuffers ? { buffers: nextBuffers } : {}),
projectActionRuns: nextRuns,
};
if (nextBuffers) {
nextState.buffers = nextBuffers;
}
if (actionMutationRevisions) {
nextState.actionMutationRevisions = actionMutationRevisions;
}
return nextState;
});
},
clearAll: () => {
set({ sessions: new Map(), buffers: new Map(), projectActionRuns: {}, nextChunkId: 1, nextTabId: 1 });
set({ sessions: new Map(), buffers: new Map(), actionMutationRevisions: new Map(), nextActionMutationRevision: 1, nextChunkId: 1, nextTabId: 1 });
},
}),
{
@@ -864,6 +1134,10 @@ export const useTerminalStore = create<TerminalStore>()(
}
const id = num === null ? persistedId : createTerminalTabId();
migratedTabIds.set(persistedId, id);
const persistedPurpose = persistedProjectActionPurposeSchema.safeParse(rawTab.purpose).data;
const purpose: TerminalTabPurpose = persistedPurpose
? { type: 'project-action', actionId: persistedPurpose.actionId, executionId: null }
: { type: 'terminal' };
tabs.push({
id,
@@ -871,6 +1145,7 @@ export const useTerminalStore = create<TerminalStore>()(
iconKey: typeof rawTab.iconKey === 'string' ? rawTab.iconKey : null,
terminalSessionId: null,
lifecycle: 'idle',
purpose,
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
isConnecting: false,
previewUrl: null,
@@ -905,6 +1180,8 @@ export const useTerminalStore = create<TerminalStore>()(
...currentState,
sessions,
buffers: new Map(),
actionMutationRevisions: new Map(),
nextActionMutationRevision: 1,
nextChunkId: 1,
nextTabId,
hasHydrated: true,
@@ -1,9 +1,15 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { CONTEXT_SURFACES, sortContextSurfaces } from '../lib/surfaces/registry';
import { useTerminalStore } from './useTerminalStore';
import { useUIStore } from './useUIStore';
const getContextPanelTabs = (directory: string) => useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
const getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal');
beforeEach(() => {
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
useTerminalStore.getState().clearAll();
});
describe('useUIStore context panel tabs', () => {
@@ -205,6 +211,168 @@ describe('useUIStore context panel tabs', () => {
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(false);
});
test('stores a terminal target under the host directory without creating a target root', () => {
useUIStore.getState().openContextPanelTab('/repo-worktree', {
mode: 'terminal',
targetDirectory: '/repo',
});
const worktreeState = useUIStore.getState().contextPanelByDirectory['/repo-worktree'];
const terminalTab = getTerminalTab('/repo-worktree');
expect(worktreeState?.activeTabId).toBe('terminal');
expect(worktreeState?.tabs).toHaveLength(1);
expect(terminalTab?.targetDirectory).toBe('/repo');
expect(useUIStore.getState().contextPanelByDirectory['/repo']).toBe(undefined);
});
test('normalizes terminal targets and canonicalizes same-host targets to null', () => {
useUIStore.getState().openContextPanelTab('/repo-worktree//', {
mode: 'terminal',
targetDirectory: ' \\repo\\nested\\ ',
});
let terminalTab = getTerminalTab('/repo-worktree');
expect(terminalTab?.targetDirectory).toBe('/repo/nested');
useUIStore.getState().openContextPanelTab('/repo-worktree//', {
mode: 'terminal',
targetDirectory: '/repo-worktree',
});
terminalTab = getTerminalTab('/repo-worktree');
expect(terminalTab?.targetDirectory).toBe(null);
});
test('reopening a terminal tab with null clears a previous target directory', () => {
useUIStore.getState().openContextPanelTab('/repo-worktree', {
mode: 'terminal',
targetDirectory: '/repo',
});
useUIStore.getState().openContextPanelTab('/repo-worktree', {
mode: 'terminal',
targetDirectory: null,
});
const terminalTab = getTerminalTab('/repo-worktree');
expect(terminalTab?.targetDirectory).toBe(null);
});
test('legacy terminal tabs without a target directory sanitize to null on touch', () => {
// SAFETY: the object mirrors the persisted context-panel shape exactly;
// setState bypasses the persist middleware's typing, not its migration.
useUIStore.setState({
contextPanelByDirectory: {
'/repo-worktree': {
isOpen: true,
expanded: false,
widthByMode: {},
touchedAt: 1,
activeTabId: 'terminal',
tabs: [
{
id: 'terminal',
mode: 'terminal',
targetPath: null,
dedupeKey: 'terminal',
label: null,
sessionTitleFallback: null,
readOnly: false,
stagedDiff: false,
diffScope: null,
touchedAt: 1,
},
],
},
},
} as never);
useUIStore.getState().openContextPanelTab('/repo-worktree', { mode: 'diff' });
const terminalTab = getTerminalTab('/repo-worktree');
expect(terminalTab?.targetDirectory).toBe(null);
});
test('persisted terminal tabs keep a normalized target through a rehydration-like touch', () => {
// SAFETY: the object mirrors the persisted context-panel shape exactly;
// setState bypasses the persist middleware's typing, not its migration.
useUIStore.setState({
contextPanelByDirectory: {
'/repo-worktree': {
isOpen: true,
expanded: false,
widthByMode: {},
touchedAt: 1,
activeTabId: 'terminal',
tabs: [
{
id: 'terminal',
mode: 'terminal',
targetPath: null,
targetDirectory: ' \\repo\\nested\\ ',
dedupeKey: 'terminal',
label: null,
sessionTitleFallback: null,
readOnly: false,
stagedDiff: false,
diffScope: null,
touchedAt: 1,
},
],
},
},
} as never);
useUIStore.getState().openContextPanelTab('/repo-worktree', { mode: 'diff' });
const terminalTab = getTerminalTab('/repo-worktree');
expect(terminalTab?.targetDirectory).toBe('/repo/nested');
});
test('ignores targetDirectory on non-terminal descriptors and sanitized tabs', () => {
useUIStore.getState().openContextPanelTab('/repo-worktree', {
mode: 'diff',
targetDirectory: '/repo',
});
const diffTab = getContextPanelTabs('/repo-worktree').find((tab) => tab.mode === 'diff');
expect(diffTab?.targetDirectory).toBe(null);
// SAFETY: the object mirrors the persisted context-panel shape exactly;
// setState bypasses the persist middleware's typing, not its migration.
useUIStore.setState({
contextPanelByDirectory: {
'/repo-worktree': {
isOpen: true,
expanded: false,
widthByMode: {},
touchedAt: 1,
activeTabId: 'diff',
tabs: [
{
id: 'diff',
mode: 'diff',
targetPath: '/repo/file.ts',
targetDirectory: '/stale',
dedupeKey: 'diff',
label: null,
sessionTitleFallback: null,
readOnly: false,
stagedDiff: false,
diffScope: 'working',
touchedAt: 1,
},
],
},
},
} as never);
useUIStore.getState().openContextPanelTab('/repo-worktree', { mode: 'terminal' });
const sanitizedDiffTab = getContextPanelTabs('/repo-worktree').find((tab) => tab.mode === 'diff');
expect(sanitizedDiffTab?.targetDirectory).toBe(null);
});
});
describe('useUIStore openContextSurface', () => {
@@ -272,6 +440,105 @@ describe('useUIStore openContextSurface', () => {
expect(activeTab?.mode).toBe('file');
expect(activeTab?.targetPath).toBe('/repo/b.ts');
});
test('opening the terminal surface clears a stale target on the singleton tab', () => {
useUIStore.getState().openContextPanelTab(directory, {
mode: 'terminal',
targetDirectory: '/repo-target',
});
useUIStore.getState().openContextSurface(directory, 'terminal');
const terminalTab = getTerminalTab(directory);
expect(terminalTab?.targetDirectory).toBe(null);
});
test('opening the terminal surface retains the target when the target directory still has a running project action', () => {
// Revisit design: manual terminal open no longer clears a still-live
// project-action target just to force the host shell back into view.
useTerminalStore.getState().ensureDirectory('/repo-target');
const targetTabId = useTerminalStore.getState().getDirectoryState('/repo-target')!.tabs[0]!.id;
useTerminalStore.getState().setTabPurpose('/repo-target', targetTabId, {
type: 'project-action',
actionId: 'build',
executionId: 'exec-1',
});
useTerminalStore.getState().setTabLifecycle('/repo-target', targetTabId, 'running', { expectedExecutionId: 'exec-1' });
useUIStore.getState().openContextPanelTab(directory, {
mode: 'terminal',
targetDirectory: '/repo-target',
});
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
useUIStore.getState().openContextSurface(directory, 'terminal');
const state = useUIStore.getState().contextPanelByDirectory[directory];
const terminalTab = getTerminalTab(directory);
expect(state?.activeTabId).toBe('terminal');
expect(state?.isOpen).toBe(true);
expect(terminalTab?.targetDirectory).toBe('/repo-target');
});
test('opening the terminal surface retains the target for a hydrated idle project-action placeholder', () => {
useTerminalStore.getState().ensureDirectory('/repo-target');
const targetTabId = useTerminalStore.getState().getDirectoryState('/repo-target')!.tabs[0]!.id;
useTerminalStore.getState().setTabPurpose('/repo-target', targetTabId, {
type: 'project-action',
actionId: 'build',
executionId: null,
});
useUIStore.getState().openContextPanelTab(directory, {
mode: 'terminal',
targetDirectory: '/repo-target',
});
useUIStore.getState().openContextSurface(directory, 'terminal');
const terminalTab = getTerminalTab(directory);
expect(terminalTab?.targetDirectory).toBe('/repo-target');
});
test('opening the terminal surface clears the target when every action tab in the target directory is exited', () => {
useTerminalStore.getState().ensureDirectory('/repo-target');
const firstTargetTabId = useTerminalStore.getState().getDirectoryState('/repo-target')!.tabs[0]!.id;
useTerminalStore.getState().setTabPurpose('/repo-target', firstTargetTabId, {
type: 'project-action',
actionId: 'build',
executionId: 'exec-1',
});
useTerminalStore.getState().setTabLifecycle('/repo-target', firstTargetTabId, 'exited', { expectedExecutionId: 'exec-1' });
const secondTargetTabId = useTerminalStore.getState().createTab('/repo-target');
useTerminalStore.getState().setTabPurpose('/repo-target', secondTargetTabId, {
type: 'project-action',
actionId: 'test',
executionId: 'exec-2',
});
useTerminalStore.getState().setTabLifecycle('/repo-target', secondTargetTabId, 'exited', { expectedExecutionId: 'exec-2' });
useUIStore.getState().openContextPanelTab(directory, {
mode: 'terminal',
targetDirectory: '/repo-target',
});
useUIStore.getState().openContextSurface(directory, 'terminal');
const terminalTab = getTerminalTab(directory);
expect(terminalTab?.targetDirectory).toBe(null);
});
test('opening the terminal surface clears the target when the target directory has no terminal state', () => {
useUIStore.getState().openContextPanelTab(directory, {
mode: 'terminal',
targetDirectory: '/repo-target',
});
useUIStore.getState().openContextSurface(directory, 'terminal');
const terminalTab = getTerminalTab(directory);
expect(terminalTab?.targetDirectory).toBe(null);
});
});
describe('useUIStore closeContextPanelTab surface stability', () => {
+66 -18
View File
@@ -9,6 +9,7 @@ import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOpt
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import type { LinearIssueListAssignee, LinearIssueListPriority, LinearIssueListStatus, TerminalShell } from '@/lib/api/types';
import type { ProjectRef } from '@/lib/projectContextApi';
import { directoryMayHaveActiveProjectAction, useTerminalStore } from '@/stores/useTerminalStore';
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
import { isWindowsArm64 } from '@/lib/platform';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -108,6 +109,7 @@ type ContextPanelTab = {
id: string;
mode: ContextPanelMode;
targetPath: string | null;
targetDirectory: string | null;
/** Saved project plan this tab shows, for `plan` tabs opened from the notes
panel. Project plans are addressed by id because their markdown is
server-owned and has no client-visible path. */
@@ -128,6 +130,7 @@ type ContextPanelTab = {
type ContextPanelTabDescriptor = {
mode: ContextPanelMode;
targetPath?: string | null;
targetDirectory?: string | null;
projectPlanId?: string | null;
projectPlanRef?: ProjectRef | null;
dedupeKey?: string | null;
@@ -252,6 +255,15 @@ const normalizeContextTargetPath = (value: string | null | undefined): string |
return trimmed.replace(/\\/g, '/');
};
const normalizeContextTargetDirectory = (value: string | null | undefined): string | null => {
const normalizedPath = normalizeContextTargetPath(value);
if (!normalizedPath) {
return null;
}
return normalizeContextPanelDirectoryKey(normalizedPath) || null;
};
const normalizeContextTabLabel = (value: string | null | undefined): string | null => {
if (typeof value !== 'string') {
return null;
@@ -320,6 +332,9 @@ const buildContextPanelTabID = (mode: ContextPanelMode, dedupeKey: string): stri
const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPanelTab => {
const normalizedTargetPath = normalizeContextTargetPath(descriptor.targetPath);
const normalizedTargetDirectory = descriptor.mode === 'terminal'
? normalizeContextTargetDirectory(descriptor.targetDirectory)
: null;
const dedupeKey = normalizeContextPanelTabDedupeKey(
descriptor.mode,
normalizedTargetPath,
@@ -329,6 +344,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
id: buildContextPanelTabID(descriptor.mode, dedupeKey),
mode: descriptor.mode,
targetPath: normalizedTargetPath,
targetDirectory: normalizedTargetDirectory,
projectPlanId: typeof descriptor.projectPlanId === 'string' && descriptor.projectPlanId.trim()
? descriptor.projectPlanId.trim()
: null,
@@ -392,6 +408,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
const candidate = entry as {
mode?: unknown;
targetPath?: unknown;
targetDirectory?: string | null;
projectPlanId?: unknown;
projectPlanRef?: unknown;
dedupeKey?: unknown;
@@ -417,6 +434,9 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
}
const targetPath = normalizeContextTargetPath(typeof candidate.targetPath === 'string' ? candidate.targetPath : null);
const targetDirectory = candidate.mode === 'terminal'
? normalizeContextTargetDirectory(candidate.targetDirectory)
: null;
const projectPlanId = typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
? candidate.projectPlanId.trim()
: null;
@@ -445,6 +465,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
id,
mode: candidate.mode,
targetPath,
targetDirectory,
projectPlanId,
projectPlanRef,
dedupeKey,
@@ -511,22 +532,23 @@ const upsertContextPanelTab = (
const existingIndex = baseTabs.findIndex((tab) => tab.id === nextTab.id);
const tabs = existingIndex === -1
? [...baseTabs, nextTab]
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
targetPath: nextTab.targetPath || tab.targetPath,
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
: tab));
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
targetPath: nextTab.targetPath || tab.targetPath,
targetDirectory: nextTab.targetDirectory,
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
: tab));
// A background upsert (an agent working a page) keeps the panel exactly as
// the user left it: closed stays closed, and whatever tab they were on
@@ -652,6 +674,7 @@ const sanitizeContextPanelByDirectory = (
touchedAt?: unknown;
mode?: unknown;
targetPath?: unknown;
targetDirectory?: string | null;
dedupeKey?: unknown;
label?: unknown;
};
@@ -663,10 +686,11 @@ const sanitizeContextPanelByDirectory = (
// no owner and cannot be migrated into an openable saved-plan tab — that
// combination is dropped by sanitize above. A generic filesystem plan tab
// (no plan id) revives fine from the descriptor alone.
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat')) {
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat' || candidate.mode === 'terminal')) {
tabs = [createContextPanelTab({
mode: candidate.mode,
targetPath: typeof candidate.targetPath === 'string' ? candidate.targetPath : null,
targetDirectory: candidate.targetDirectory,
dedupeKey: typeof candidate.dedupeKey === 'string' ? candidate.dedupeKey : null,
label: typeof candidate.label === 'string' ? candidate.label : null,
})];
@@ -1372,14 +1396,29 @@ export const useUIStore = create<UIStore>()(
const panelState = state.contextPanelByDirectory[normalizedDirectory];
const tabs = panelState?.tabs ?? [];
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? null;
const clearTerminalTarget = () => {
if (mode === 'terminal') {
const terminalTab = tabs.find((tab) => tab.mode === 'terminal') ?? null;
const targetDirectory = terminalTab?.targetDirectory ?? null;
if (targetDirectory) {
const targetState = useTerminalStore.getState().getDirectoryState(targetDirectory);
if (directoryMayHaveActiveProjectAction(targetState)) {
return;
}
}
state.openContextPanelTab(normalizedDirectory, { mode: 'terminal', targetDirectory: null }, { reveal: false });
}
};
if (panelState?.isOpen && activeTab?.mode === mode) {
clearTerminalTarget();
state.closeContextPanel(normalizedDirectory);
return;
}
const tabsOfMode = tabs.filter((tab) => tab.mode === mode);
if (tabsOfMode.length > 0) {
clearTerminalTarget();
// `>=` so equal timestamps (same-millisecond opens) resolve to the
// later tab in insertion order.
const mostRecent = tabsOfMode.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best));
@@ -1403,12 +1442,21 @@ export const useUIStore = create<UIStore>()(
return;
}
const nextTab = tab.mode === 'terminal'
? {
...tab,
targetDirectory: normalizeContextTargetDirectory(tab.targetDirectory) === normalizedDirectory
? null
: normalizeContextTargetDirectory(tab.targetDirectory),
}
: tab;
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: upsertContextPanelTab(current, tab, options),
[normalizedDirectory]: upsertContextPanelTab(current, nextTab, options),
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
@@ -70,6 +70,7 @@ const deferredStorage: Storage = {
mock.module("@/stores/utils/safeStorage", () => ({
getDeferredSafeStorage: () => deferredStorage,
getSafeSessionStorage: () => deferredStorage,
createDeferredSafeJSONStorage: () => ({
getItem: async () => null,
setItem: async () => undefined,
@@ -2,7 +2,7 @@
## Ownership
`runtime.js` owns terminal identity, PTY processes, status, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families and resolves the persisted shell ID without accepting command strings or arguments. Clients own tab arrangement and choose stable terminal IDs. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error.
`runtime.js` owns terminal identity, PTY processes, launch mode, session purpose, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families, resolves the persisted shell ID, and builds the per-shell argv for interactive versus command launches. Clients own tab arrangement and choose stable terminal IDs. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error.
## Protocol
@@ -19,21 +19,23 @@
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. Listings include the effective launch mode and the normalized purpose, but never the command text. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
## PTY Lifecycle
- IDs are client-provided or generated with `randomUUID()`.
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
- Create defaults to interactive mode. Command mode requires a non-empty trimmed command no longer than the terminal input limit and launches the shell so the PTY exits when that command exits. Each session also carries a normalized purpose. Omitted purpose means `{ type: 'terminal' }`. Project actions use `{ type: 'project-action', actionId, executionId }`, and the server validates both IDs as non-empty bounded strings. Create responses and attach snapshots echo the effective mode and purpose.
- Concurrent creates for one ID are single-flight only when working directory, shell preference, login mode, launch mode, and session purpose match. Command-mode creates must also match the command text, unless the purpose is a project action that is already running for the same resolved `(cwd, actionId)` pair. In that case the runtime returns the existing session and its existing execution identity, even when another client requested a different session ID. Existing IDs cannot be reused for another working directory or another purpose.
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Interactive shells still launch as before. Command-mode launches reuse the same environment and login support, but switch argv by shell family: POSIX and Fish use interactive `-c`, Nushell uses `-c`, PowerShell uses `-Command`, and cmd uses `/d /s /c`. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. The runtime wires those listeners in the same synchronous turn that receives the PTY object. `node-pty` and `bun-pty` both expose the PTY before dispatching registered callbacks. If a backend emitted exit before listener registration, this layer could not recover it, so the wiring stays adjacent to PTY creation.
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
- Exited sessions remain attachable until explicit close or idle cleanup.
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID.
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata.
- A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds.
- Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.
## Security And Relay
+126 -14
View File
@@ -9,7 +9,7 @@ import {
} from './terminal-ws-protocol.js';
import { sanitizeTerminalHistoryChunk } from './history.js';
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js';
import { buildTerminalShellLaunch, createTerminalShellResolver, normalizeTerminalShell } from './shells.js';
import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js';
const MAX_SESSIONS = 20;
@@ -17,7 +17,66 @@ const MAX_HISTORY_BYTES = 512 * 1024;
const MAX_INPUT_CHARS = 65_536;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const TERMINATION_GRACE_MS = 1000;
const INTERACTIVE_TERMINAL_MODE = 'interactive';
const COMMAND_TERMINAL_MODE = 'command';
const TERMINAL_PURPOSE = Object.freeze({ type: 'terminal' });
const MAX_PURPOSE_ID_CHARS = 128;
const OBJECT_TAG = '[object Object]';
const validateSize = (value, max) => Number.isInteger(value) && value >= 1 && value <= max;
const isString = (value) => String(value) === value;
const isObjectRecord = (value) => value != null && !Array.isArray(value) && Object.prototype.toString.call(value) === OBJECT_TAG;
const normalizeCreateMode = ({ mode, command }) => {
const normalizedMode = mode == null ? INTERACTIVE_TERMINAL_MODE : mode;
if (normalizedMode !== INTERACTIVE_TERMINAL_MODE && normalizedMode !== COMMAND_TERMINAL_MODE) throw new Error('Invalid terminal mode');
if (normalizedMode === INTERACTIVE_TERMINAL_MODE) {
if (command != null) throw new Error('Interactive terminal create does not accept a command');
return { mode: INTERACTIVE_TERMINAL_MODE, command: null };
}
if (!isString(command) || !command.trim()) throw new Error('Terminal command is required');
const trimmedCommand = command.trim();
if (trimmedCommand.length > MAX_INPUT_CHARS) throw new Error('Terminal command exceeds the input limit');
return { mode: COMMAND_TERMINAL_MODE, command: trimmedCommand };
};
const normalizePurposeId = (value, errorMessage) => {
if (!isString(value) || !value.trim()) throw new Error(errorMessage);
const normalized = value.trim();
if (normalized.length > MAX_PURPOSE_ID_CHARS) throw new Error(errorMessage);
return normalized;
};
const normalizeTerminalPurpose = (value) => {
if (value == null) return TERMINAL_PURPOSE;
if (!isObjectRecord(value)) throw new Error('Invalid terminal purpose');
if (value.type === 'terminal') return TERMINAL_PURPOSE;
if (value.type !== 'project-action') throw new Error('Invalid terminal purpose');
return {
type: 'project-action',
actionId: normalizePurposeId(value.actionId, 'Terminal project action id is required'),
executionId: normalizePurposeId(value.executionId, 'Terminal execution id is required'),
};
};
const getSessionPurpose = (session) => session.purpose ?? TERMINAL_PURPOSE;
const isPurposeActionMatch = (left, right) => {
if (left.type !== right.type) return false;
return left.type !== 'project-action' || left.actionId === right.actionId;
};
const findRunningActionSession = (sessions, resolvedCwd, purpose, path) => {
if (purpose.type !== 'project-action') return null;
for (const session of sessions.values()) {
if (session.status !== 'running') continue;
if (path.resolve(session.cwd) !== resolvedCwd) continue;
const sessionPurpose = getSessionPurpose(session);
if (sessionPurpose.type === 'project-action' && sessionPurpose.actionId === purpose.actionId) return session;
}
return null;
};
const findPendingActionCreate = (pendingSessionCreates, resolvedCwd, purpose) => {
if (purpose.type !== 'project-action') return null;
for (const pending of pendingSessionCreates.values()) {
if (pending.cancelled || pending.cwd !== resolvedCwd) continue;
if (pending.purpose?.type === 'project-action' && pending.purpose.actionId === purpose.actionId) return pending;
}
return null;
};
const trimHistory = (history) => {
const bytes = Buffer.from(history);
if (bytes.byteLength <= MAX_HISTORY_BYTES) return history;
@@ -54,13 +113,11 @@ export function createTerminalRuntime({
return ptyProviderPromise;
};
const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell }) => {
const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell, mode, command }) => {
const provider = await getPtyProvider();
const resolvedShell = await shellResolver.resolve(shell);
let lastError = null;
for (const executable of resolvedShell.executables) {
const args = loginShell ? getTerminalShellLoginArgs(executable) : [];
if (!args) throw new Error(`Terminal shell "${resolvedShell.id}" does not support login mode`);
try {
const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' };
// The daemon's IPC fd is closed inside the PTY. An explicit override is
@@ -70,9 +127,11 @@ export function createTerminalRuntime({
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
stripAppImageArgv0Leak(env);
const launch = resolveLinuxPtyLaunch(executable, args);
const options = { name: 'xterm-256color', cwd, cols, rows, env, ...(process.platform === 'win32' ? { useConpty: true } : {}) };
return { process: provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
const launch = resolveLinuxPtyLaunch(shellLaunch.executable, shellLaunch.args);
const options = { name: 'xterm-256color', cwd, cols, rows, env };
if (process.platform === 'win32') options.useConpty = true;
return { process: await provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
} catch (error) { lastError = error; }
}
throw lastError ?? new Error('No executable shell found');
@@ -123,6 +182,7 @@ export function createTerminalRuntime({
const snapshot = (session) => ({
t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
status: session.status, exitCode: session.exitCode, signal: session.signal,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
runtime, ptyBackend: session.backend,
});
@@ -192,48 +252,78 @@ export function createTerminalRuntime({
}
};
const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell }, clear = true) => {
const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell, mode = INTERACTIVE_TERMINAL_MODE, command = null, purpose = TERMINAL_PURPOSE }, clear = true) => {
await validateCwd(cwd);
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell });
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell, mode, command });
if (clear) { session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; }
session.cwd = cwd; session.cols = cols; session.rows = rows; session.process = spawned.process;
session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.status = 'running'; session.exitCode = null; session.signal = null;
session.mode = mode; session.command = mode === COMMAND_TERMINAL_MODE ? command : null;
session.purpose = purpose;
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
session.lastActivity = Date.now(); session.eventQueue.length = 0;
wire(session, spawned.process);
return spawned.process;
};
const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false }) => {
const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false, mode, command, purpose }) => {
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions');
if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode');
const normalizedShell = normalizeTerminalShell(shell);
if (!normalizedShell) throw new Error('Invalid terminal shell');
const launchMode = normalizeCreateMode({ mode, command });
const normalizedPurpose = normalizeTerminalPurpose(purpose);
const id = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : randomUUID();
if (id.length > 128) throw new Error('Invalid terminal session id');
const existing = sessions.get(id);
const resolvedCwd = path.resolve(cwd);
if (existing?.status === 'running') {
if (path.resolve(existing.cwd) !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
if (!isPurposeActionMatch(getSessionPurpose(existing), normalizedPurpose)) throw new Error('Terminal session is already running with a different purpose');
if (normalizedPurpose.type === 'project-action') { applyAppearance(existing, { themeMode, terminalBackground, terminalForeground }); return existing; }
if ((existing.mode ?? INTERACTIVE_TERMINAL_MODE) !== launchMode.mode) throw new Error('Terminal session is already running with a different mode');
if (launchMode.mode === COMMAND_TERMINAL_MODE && existing.command !== launchMode.command) throw new Error('Terminal session is already running with a different command');
applyAppearance(existing, { themeMode, terminalBackground, terminalForeground });
return existing;
}
const runningActionSession = findRunningActionSession(sessions, resolvedCwd, normalizedPurpose, path);
if (runningActionSession) {
applyAppearance(runningActionSession, { themeMode, terminalBackground, terminalForeground });
return runningActionSession;
}
const pending = pendingSessionCreates.get(id);
if (pending) {
if (pending.cwd !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
if (!isPurposeActionMatch(pending.purpose, normalizedPurpose)) throw new Error('Terminal session is already being created with a different purpose');
if (normalizedPurpose.type === 'project-action') return pending.promise;
if (pending.shell !== normalizedShell) throw new Error('Terminal session is already being created with a different shell');
if (pending.loginShell !== loginShell) throw new Error('Terminal session is already being created with a different login mode');
if (pending.mode !== launchMode.mode) throw new Error('Terminal session is already being created with a different mode');
if (launchMode.mode === COMMAND_TERMINAL_MODE && pending.command !== launchMode.command) throw new Error('Terminal session is already being created with a different command');
const session = await pending.promise;
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
return session;
}
const pendingActionCreate = findPendingActionCreate(pendingSessionCreates, resolvedCwd, normalizedPurpose);
if (pendingActionCreate) {
const session = await pendingActionCreate.promise;
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
return session;
}
if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose, cancelled: false, promise: null };
const creation = (async () => {
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell });
const ptyProcess = await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose });
if (pendingEntry.cancelled) {
session.process = null;
await terminateProcess(ptyProcess, true);
throw new Error('Terminal session was closed during creation');
}
sessions.set(id, session);
return session;
})();
const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, promise: creation };
pendingEntry.promise = creation;
pendingSessionCreates.set(id, pendingEntry);
try { return await creation; }
finally { if (pendingSessionCreates.get(id) === pendingEntry) pendingSessionCreates.delete(id); }
@@ -331,6 +421,8 @@ export function createTerminalRuntime({
cwd: session.cwd,
status: session.status,
createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
purpose: getSessionPurpose(session),
});
}
res.json({ sessions: list });
@@ -349,7 +441,17 @@ export function createTerminalRuntime({
res.json({ touched });
});
app.post('/api/terminal/create', async (req, res) => {
try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); }
try {
const session = await createSession(req.body ?? {});
res.json({
sessionId: session.id,
cols: session.cols,
rows: session.rows,
status: session.status,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
purpose: getSessionPurpose(session),
});
}
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); }
});
app.post('/api/terminal/:sessionId/resize', (req, res) => {
@@ -369,6 +471,7 @@ export function createTerminalRuntime({
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
if ((session.mode ?? INTERACTIVE_TERMINAL_MODE) === COMMAND_TERMINAL_MODE) return res.status(400).json({ error: 'Command-mode terminal sessions cannot be restarted' });
const cwd = req.body?.cwd ?? session.cwd;
const cols = req.body?.cols ?? session.cols;
const rows = req.body?.rows ?? session.rows;
@@ -398,7 +501,16 @@ export function createTerminalRuntime({
});
app.delete('/api/terminal/:sessionId', async (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
if (!session) {
const pending = pendingSessionCreates.get(req.params.sessionId);
if (!pending) return res.status(404).json({ error: 'Terminal session not found' });
pending.cancelled = true;
try { await pending.promise; }
catch (error) {
if (error?.message !== 'Terminal session was closed during creation') throw error;
}
return res.json({ success: true });
}
sessions.delete(session.id);
closeAttachments(session.id, 'CLOSED', 'Terminal closed');
await terminateProcess(session.process);
+480 -26
View File
@@ -3,7 +3,6 @@ import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import express from 'express';
import { WebSocket } from 'ws';
import { createTerminalRuntime } from './runtime.js';
@@ -24,6 +23,98 @@ function createResponse() {
};
}
async function openTerminalSocket(socketUrl) {
const socket = new WebSocket(socketUrl);
const messages = [];
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
await new Promise((resolve, reject) => {
socket.once('open', resolve);
socket.once('error', reject);
});
const next = async (type, sessionId) => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
if (index >= 0) return messages.splice(index, 1)[0];
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error(`Timed out waiting for ${type}`);
};
await next('hello');
return { socket, next, messages };
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createHttpTestApp() {
const routes = { GET: [], POST: [], DELETE: [] };
const app = (req, res) => {
const methodRoutes = routes[req.method] ?? [];
const url = new URL(req.url, 'http://127.0.0.1');
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
const bodyText = Buffer.concat(chunks).toString('utf8');
const route = methodRoutes.find(({ pattern }) => pattern.test(url.pathname));
if (!route) {
res.statusCode = 404;
res.end('Not found');
return;
}
const match = route.pattern.exec(url.pathname);
const response = {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
this.body = payload;
return this;
},
};
try {
await route.handler({
method: req.method,
url: req.url,
query: Object.fromEntries(url.searchParams.entries()),
params: route.params.reduce((acc, name, index) => ({ ...acc, [name]: match[index + 1] }), {}),
body: bodyText ? JSON.parse(bodyText) : {},
}, response);
} catch (error) {
response.status(500).json({ error: error?.message || 'Route failed' });
}
res.writeHead(response.statusCode, response.headers);
res.end(JSON.stringify(response.body));
});
};
const register = (method, route, handler) => {
const params = [];
const escaped = route.replace(/:([^/]+)/g, (_, name) => {
params.push(name);
return '([^/]+)';
});
routes[method].push({
pattern: new RegExp(`^${escaped}$`),
params,
handler,
});
};
app.get = (route, handler) => register('GET', route, handler);
app.post = (route, handler) => register('POST', route, handler);
app.delete = (route, handler) => register('DELETE', route, handler);
return app;
}
function createRuntime(server, overrides = {}) {
const app = overrides.app ?? {
post() {},
@@ -54,6 +145,7 @@ describe('terminal runtime', () => {
const createHarness = (overrides = {}) => {
const routes = { get: new Map(), post: new Map(), delete: new Map() };
const processes = [];
const spawnDeferred = overrides.spawnDeferred ?? null;
const app = {
post(route, handler) { routes.post.set(route, handler); },
get(route, handler) { routes.get.set(route, handler); },
@@ -61,7 +153,8 @@ describe('terminal runtime', () => {
};
const loadPtyProvider = async () => ({
backend: 'fake-pty',
spawn: (shell, args, options) => {
spawn: async (shell, args, options) => {
await spawnDeferred?.promise;
const dataHandlers = new Set();
const exitHandlers = new Set();
const process = {
@@ -150,7 +243,7 @@ describe('terminal runtime', () => {
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', cols: 120, rows: 40, themeMode: 'light', terminalBackground: '#faf8f0', terminalForeground: '#1b1b1b' } }, response);
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running' });
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running', mode: 'interactive', purpose: { type: 'terminal' } });
expect(harness.processes[0].options.cwd).toBe('/repo');
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
@@ -192,7 +285,7 @@ describe('terminal runtime', () => {
const scoped = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
expect(scoped.body.sessions).toEqual([
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) },
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number), mode: 'interactive', purpose: { type: 'terminal' } },
]);
const touch = createResponse();
@@ -466,8 +559,7 @@ describe('terminal runtime', () => {
});
it('runs snapshot-first attach, scoped I/O, replay, reconnect, and close over a real websocket', async () => {
const app = express();
app.use(express.json());
const app = createHttpTestApp();
const server = http.createServer(app);
const processes = [];
const loadPtyProvider = async () => ({
@@ -501,24 +593,6 @@ describe('terminal runtime', () => {
const socketUrl = `ws://127.0.0.1:${address.port}/api/terminal/ws`;
const sockets = [];
const open = async () => {
const socket = new WebSocket(socketUrl);
sockets.push(socket);
const messages = [];
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
const next = async (type, sessionId) => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
if (index >= 0) return messages.splice(index, 1)[0];
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error(`Timed out waiting for ${type}`);
};
await next('hello');
return { socket, next, messages };
};
try {
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST', headers: { 'content-type': 'application/json' },
@@ -531,7 +605,8 @@ describe('terminal runtime', () => {
});
expect(secondCreated.status).toBe(200);
const first = await open();
const first = await openTerminalSocket(socketUrl);
sockets.push(first.socket);
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
@@ -559,7 +634,8 @@ describe('terminal runtime', () => {
expect(secondClosed.status).toBe(200);
first.socket.close();
const second = await open();
const second = await openTerminalSocket(socketUrl);
sockets.push(second.socket);
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
processes[0].emitExit(7);
@@ -589,4 +665,382 @@ describe('terminal runtime', () => {
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('creates command-mode sessions and echoes the effective mode', async () => {
const harness = createHarness({
searchPathFor: (name) => name === 'bash' ? '/bin/bash' : '/bin/sh',
isExecutable: (candidate) => candidate === '/bin/bash' || candidate === '/bin/sh',
});
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready', shell: 'bash', loginShell: true } }, response);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ sessionId: 'term-command', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'terminal' } });
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
} else {
expect(harness.processes[0].args).toEqual(['-l', '-i', '-c', 'printf ready']);
}
} finally { await harness.runtime.shutdown(); }
});
it('rejects invalid terminal mode and command combinations', async () => {
const harness = createHarness();
try {
for (const [body, error] of [
[{ cwd: '/repo', mode: 'script' }, 'Invalid terminal mode'],
[{ cwd: '/repo', mode: 'command' }, 'Terminal command is required'],
[{ cwd: '/repo', mode: 'command', command: ' ' }, 'Terminal command is required'],
[{ cwd: '/repo', mode: 'interactive', command: 'echo nope' }, 'Interactive terminal create does not accept a command'],
[{ cwd: '/repo', mode: 'command', command: 'x'.repeat(65_537) }, 'Terminal command exceeds the input limit'],
]) {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body }, response);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error });
}
expect(harness.processes).toHaveLength(0);
} finally { await harness.runtime.shutdown(); }
});
it('rejects same-id running creates when mode or command do not match', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
await create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, createResponse());
const modeMismatch = createResponse();
await create({ body: { sessionId: 'term-shared', cwd: '/repo', mode: 'command', command: 'printf ready' } }, modeMismatch);
expect(modeMismatch.statusCode).toBe(400);
expect(modeMismatch.body).toEqual({ error: 'Terminal session is already running with a different mode' });
await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready' } }, createResponse());
const commandMismatch = createResponse();
await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf other' } }, commandMismatch);
expect(commandMismatch.statusCode).toBe(400);
expect(commandMismatch.body).toEqual({ error: 'Terminal session is already running with a different command' });
} finally { await harness.runtime.shutdown(); }
});
it('rejects pending creates when command mode does not match the in-flight request', async () => {
const spawnDeferred = deferred();
const harness = createHarness({ spawnDeferred });
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const conflictingMode = createResponse();
const conflictingCommand = createResponse();
const firstPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf ready' } }, first);
await Promise.resolve();
const secondPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo' } }, conflictingMode);
const thirdPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf other' } }, conflictingCommand);
spawnDeferred.resolve();
await Promise.all([firstPromise, secondPromise, thirdPromise]);
expect(first.statusCode).toBe(200);
expect(conflictingMode.statusCode).toBe(400);
expect(conflictingMode.body).toEqual({ error: 'Terminal session is already being created with a different mode' });
expect(conflictingCommand.statusCode).toBe(400);
expect(conflictingCommand.body).toEqual({ error: 'Terminal session is already being created with a different command' });
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('validates purpose payloads and round-trips purpose through create, list, and snapshot without listing command text', async () => {
const app = createHttpTestApp();
const server = http.createServer(app);
const runtime = createRuntime(server, {
app,
loadPtyProvider: async () => ({
backend: 'fake-pty',
spawn: async () => ({
pid: 42,
write() {},
resize() {},
kill() {},
onData() { return { dispose() {} }; },
onExit() { return { dispose() {} }; },
}),
}),
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh',
isExecutable: () => true,
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
const sockets = [];
try {
for (const [body, error] of [
[{ cwd: '/repo', purpose: 'terminal' }, 'Invalid terminal purpose'],
[{ cwd: '/repo', purpose: { type: 'project-action' } }, 'Terminal project action id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build' } }, 'Terminal execution id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: ' ', executionId: 'exec-1' } }, 'Terminal project action id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build', executionId: ' ' } }, 'Terminal execution id is required'],
]) {
const response = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error });
}
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'printf ready',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}),
});
expect(created.status).toBe(200);
expect(await created.json()).toEqual({
sessionId: 'action-tab',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
const listed = await fetch(`${base}/api/terminal/sessions?cwd=%2Frepo`);
expect(listed.status).toBe(200);
expect(await listed.json()).toEqual({
sessions: [{
sessionId: 'action-tab',
cwd: '/repo',
status: 'running',
createdAt: expect.any(Number),
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}],
});
const socket = await openTerminalSocket(socketUrl);
sockets.push(socket.socket);
socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'action-tab' }));
expect(await socket.next('snapshot', 'action-tab')).toMatchObject({
s: 'action-tab',
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
} finally {
for (const socket of sockets) socket.terminate();
await runtime.shutdown();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('deduplicates running project actions by resolved cwd and action id across session ids and clients', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
await create({
body: {
sessionId: 'action-a',
cwd: '/repo/./nested/..',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, first);
expect(first.statusCode).toBe(200);
const adopted = createResponse();
await create({
body: {
sessionId: 'action-b',
cwd: '/repo',
mode: 'command',
command: 'npm run build --watch',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-2' },
},
}, adopted);
expect(adopted.statusCode).toBe(200);
expect(adopted.body).toEqual({
sessionId: 'action-a',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects purpose mismatches when the same session id is reused for a different action', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
await create({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, createResponse());
const mismatch = createResponse();
await create({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run test',
purpose: { type: 'project-action', actionId: 'test', executionId: 'exec-2' },
},
}, mismatch);
expect(mismatch.statusCode).toBe(400);
expect(mismatch.body).toEqual({ error: 'Terminal session is already running with a different purpose' });
} finally { await harness.runtime.shutdown(); }
});
it('keeps an immediately exited command session attachable once listeners are registered', async () => {
const app = createHttpTestApp();
const server = http.createServer(app);
const runtime = createRuntime(server, {
app,
loadPtyProvider: async () => ({
backend: 'fake-pty',
spawn: async () => {
const dataHandlers = new Set();
const exitHandlers = new Set();
return {
pid: 404,
write() {},
resize() {},
kill() {},
onData(handler) { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; },
onExit(handler) {
exitHandlers.add(handler);
queueMicrotask(() => {
for (const registered of exitHandlers) registered({ exitCode: 0, signal: 0 });
});
return { dispose: () => exitHandlers.delete(handler) };
},
};
},
}),
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh',
isExecutable: () => true,
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
const sockets = [];
try {
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionId: 'fast-exit',
cwd: '/repo',
mode: 'command',
command: 'true',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
}),
});
expect(created.status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 0));
const socket = await openTerminalSocket(socketUrl);
sockets.push(socket.socket);
socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'fast-exit' }));
expect(await socket.next('snapshot', 'fast-exit')).toMatchObject({
s: 'fast-exit',
status: 'exited',
exitCode: 0,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
});
} finally {
for (const socket of sockets) socket.terminate();
await runtime.shutdown();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('tombstones a pending create when delete arrives first and kills the eventual pty', async () => {
const spawnDeferred = deferred();
const harness = createHarness({ spawnDeferred });
try {
const create = harness.routes.post.get('/api/terminal/create');
const close = harness.routes.delete.get('/api/terminal/:sessionId');
const created = createResponse();
const closed = createResponse();
const createPromise = create({
body: {
sessionId: 'pending-action',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-pending' },
},
}, created);
await Promise.resolve();
const closePromise = close({ params: { sessionId: 'pending-action' } }, closed);
spawnDeferred.resolve();
await Promise.all([createPromise, closePromise]);
expect(closed.statusCode).toBe(200);
expect(closed.body).toEqual({ success: true });
expect(created.statusCode).toBe(400);
expect(created.body).toEqual({ error: 'Terminal session was closed during creation' });
const listed = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, listed);
expect(listed.body).toEqual({ sessions: [] });
expect(harness.processes).toHaveLength(1);
expect(harness.processes[0].killed).toBe(true);
} finally { await harness.runtime.shutdown(); }
});
it('rejects restart for command-mode sessions', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, createResponse());
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'action-tab' }, body: {} }, restarted);
expect(restarted.statusCode).toBe(400);
expect(restarted.body).toEqual({ error: 'Command-mode terminal sessions cannot be restarted' });
expect(harness.processes).toHaveLength(1);
expect(harness.processes[0].killed).toBe(false);
} finally { await harness.runtime.shutdown(); }
});
});
@@ -1,5 +1,6 @@
const TERMINAL_SHELL_IDS = ['bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu'];
const TERMINAL_SHELL_ID_SET = new Set(TERMINAL_SHELL_IDS);
const isString = (value) => String(value) === value;
export const normalizeTerminalShell = (value) => {
if (typeof value !== 'string') return null;
@@ -24,6 +25,22 @@ export const getTerminalShellLoginArgs = (executable, platform = process.platfor
return null;
};
export const buildTerminalShellLaunch = (executable, { mode = 'interactive', command = null, loginShell = false, platform = process.platform } = {}) => {
const loginArgs = loginShell ? getTerminalShellLoginArgs(executable, platform) : [];
if (!loginArgs) throw new Error(`Terminal shell "${shellIdFromPath(executable) ?? executable}" does not support login mode`);
if (mode === 'interactive') return { executable, args: loginArgs };
const trimmedCommand = isString(command) ? command.trim() : '';
if (!trimmedCommand) throw new Error('Terminal command is required');
const id = shellIdFromPath(executable);
if (id === 'nu') return { executable, args: [...loginArgs, '-c', trimmedCommand] };
if (id === 'pwsh' || id === 'powershell') return { executable, args: [...loginArgs, '-Command', trimmedCommand] };
if (id === 'cmd') return { executable, args: ['/d', '/s', '/c', trimmedCommand] };
return { executable, args: [...loginArgs, '-i', '-c', trimmedCommand] };
};
export const createTerminalShellResolver = ({ fs, path, searchPathFor, isExecutable, buildAugmentedPath = () => env.PATH || '', platform = process.platform, env = process.env }) => {
const resolveExecutable = (candidate) => {
if (!candidate) return null;
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { createTerminalShellResolver, getTerminalShellLoginArgs } from './shells.js';
import * as shells from './shells.js';
const { createTerminalShellResolver, getTerminalShellLoginArgs } = shells;
const createResolver = ({ platform = 'linux', env = {}, augmentedPath = '/augmented/bin', executables = [] } = {}) => {
const available = new Set(executables);
@@ -70,4 +72,33 @@ describe('terminal shell resolver', () => {
expect(getTerminalShellLoginArgs('C:\\Program Files\\PowerShell\\7\\pwsh.exe', 'win32')).toBeNull();
expect(getTerminalShellLoginArgs('/bin/dash', 'linux')).toBeNull();
});
it('builds interactive shell launches by shell family', () => {
const buildLaunch = shells.buildTerminalShellLaunch;
expect(buildLaunch('/bin/bash', { mode: 'interactive', loginShell: true, platform: 'linux' })).toEqual({ executable: '/bin/bash', args: ['-l'] });
expect(buildLaunch('/opt/homebrew/bin/fish', { mode: 'interactive', loginShell: true, platform: 'darwin' })).toEqual({ executable: '/opt/homebrew/bin/fish', args: ['--login'] });
expect(buildLaunch('/usr/bin/nu', { mode: 'interactive', loginShell: true, platform: 'linux' })).toEqual({ executable: '/usr/bin/nu', args: ['--login'] });
expect(buildLaunch('/usr/bin/pwsh', { mode: 'interactive', loginShell: true, platform: 'linux' })).toEqual({ executable: '/usr/bin/pwsh', args: ['-Login'] });
expect(buildLaunch('C:\\Windows\\System32\\cmd.exe', { mode: 'interactive', loginShell: false, platform: 'win32' })).toEqual({ executable: 'C:\\Windows\\System32\\cmd.exe', args: [] });
});
it('builds command launches by shell family', () => {
const buildLaunch = shells.buildTerminalShellLaunch;
expect(buildLaunch('/bin/zsh', { mode: 'command', command: 'printf ready', loginShell: true, platform: 'linux' })).toEqual({ executable: '/bin/zsh', args: ['-l', '-i', '-c', 'printf ready'] });
expect(buildLaunch('/opt/homebrew/bin/fish', { mode: 'command', command: 'echo ready', loginShell: true, platform: 'darwin' })).toEqual({ executable: '/opt/homebrew/bin/fish', args: ['--login', '-i', '-c', 'echo ready'] });
expect(buildLaunch('/usr/bin/nu', { mode: 'command', command: 'ls', loginShell: true, platform: 'linux' })).toEqual({ executable: '/usr/bin/nu', args: ['--login', '-c', 'ls'] });
expect(buildLaunch('/usr/bin/pwsh', { mode: 'command', command: 'Get-ChildItem', loginShell: true, platform: 'linux' })).toEqual({ executable: '/usr/bin/pwsh', args: ['-Login', '-Command', 'Get-ChildItem'] });
expect(buildLaunch('C:\\Program Files\\PowerShell\\7\\pwsh.exe', { mode: 'command', command: 'Get-Date', loginShell: false, platform: 'win32' })).toEqual({ executable: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', args: ['-Command', 'Get-Date'] });
expect(buildLaunch('C:\\Windows\\System32\\cmd.exe', { mode: 'command', command: 'dir', loginShell: false, platform: 'win32' })).toEqual({ executable: 'C:\\Windows\\System32\\cmd.exe', args: ['/d', '/s', '/c', 'dir'] });
});
it('rejects unsupported login and command combinations', () => {
const buildLaunch = shells.buildTerminalShellLaunch;
expect(() => buildLaunch('/bin/sh', { mode: 'interactive', loginShell: true, platform: 'linux' })).toThrow('does not support login mode');
expect(() => buildLaunch('/bin/dash', { mode: 'command', command: 'pwd', loginShell: true, platform: 'linux' })).toThrow('does not support login mode');
expect(() => buildLaunch('/bin/bash', { mode: 'command', command: '', loginShell: false, platform: 'linux' })).toThrow('Terminal command is required');
});
});