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,