fix(terminal): reconcile project action executions across clients (#3362)

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 12:05:39 +03:00
committed by GitHub
parent 4e0eed717d
commit d37ce34a2e
16 changed files with 640 additions and 216 deletions
@@ -60,6 +60,13 @@ interface MockedDetectedDevServer {
}
const createCalls: CreateTerminalOptions[] = [];
const createdSessionId = (index: number): string => {
const id = createCalls[index]?.sessionId;
if (!id) throw new Error('missing requested terminal ID');
return id;
};
const firstSessionId = () => createdSessionId(0);
const secondSessionId = () => createdSessionId(1);
const sendCalls: string[] = [];
const forceKillCalls: string[] = [];
const closeCalls: string[] = [];
@@ -81,7 +88,7 @@ const terminal = {
createCalls.push(options);
sessionCounter += 1;
return {
sessionId: `session-${sessionCounter}`,
sessionId: options.sessionId ?? `session-${sessionCounter}`,
cols: 80,
rows: 24,
status: 'running' as const,
@@ -243,6 +250,100 @@ describe('ProjectActionsButton lifecycle', () => {
await act(async () => { await Promise.resolve(); });
};
test('adopting a saved URL action does not auto-open a different output URL', async () => {
mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', autoOpenUrl: true, openUrl: 'http://localhost:4000' }];
const originalList = terminal.listSessions;
Object.assign(terminal, { listSessions: async () => [{
sessionId: 'peer-run', cwd: '/repo', status: 'running', createdAt: 1, mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'peer-execution' },
}] });
try {
await renderButton();
await act(async () => { emitToSession('peer-run', { type: 'snapshot', sequence: 1, status: 'running', data: 'Local: http://localhost:5173/\n' }); });
expect(openExternalCalls).toEqual([]);
} finally { terminal.listSessions = originalList; }
});
test('a second run keeps its tab and displays fresh output under a new terminal ID', async () => {
const originalCreate = terminal.createSession;
Object.assign(terminal, { createSession: async (options: CreateTerminalOptions) => {
createCalls.push(options);
return { sessionId: options.sessionId, cols: 80, rows: 24, status: 'running', mode: 'command', purpose: options.purpose };
} });
try {
await renderButton();
const button = host.querySelector('button');
if (!button) throw new Error('missing action button');
await act(async () => { button.click(); });
const tab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
if (!tab?.terminalSessionId) throw new Error('missing action terminal');
const originalSessionId = tab.terminalSessionId;
await act(async () => {
emitToSession(originalSessionId, { type: 'snapshot', status: 'running', sequence: 5, data: 'old output' });
emitToSession(originalSessionId, { type: 'exit', sequence: 6 });
});
await act(async () => { button.click(); });
expect(secondSessionId()).not.toBe(firstSessionId());
await act(async () => {
emitToSession(secondSessionId(), { type: 'snapshot', status: 'running', sequence: 1, data: 'new output' });
});
const buffer = useTerminalStore.getState().getBuffer('/repo', tab.id);
expect(buffer.chunks.map(chunk => chunk.data).join('')).toBe('new output');
} finally { terminal.createSession = originalCreate; }
});
test('a stale rerun adopts the server run without closing it', async () => {
await renderButton();
const button = host.querySelector('button');
if (!button) throw new Error('missing action button');
await act(async () => { button.click(); });
await act(async () => { emitToSession(firstSessionId(), { type: 'exit', sequence: 2 }); });
const originalList = terminal.listSessions;
const serverSession = {
sessionId: firstSessionId(), cwd: '/repo', status: 'running', createdAt: 1,
mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'other-client-new-run' },
};
Object.assign(terminal, { listSessions: async () => [serverSession] });
try {
await act(async () => { button.click(); });
expect(closeCalls).toEqual([]);
const tab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
expect(tab?.purpose).toEqual(serverSession.purpose);
} finally {
terminal.listSessions = originalList;
}
});
test('parent action manual URL opens in the current worktree panel', async () => {
effectiveDirectory = '/repo-worktree';
mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', runIn: 'parent', autoOpenUrl: true, openUrl: 'http://localhost:4000' }];
await renderButton({ projectPath: '/repo', directory: '/repo-worktree' });
const button = host.querySelector('button');
if (!button) throw new Error('missing action button');
await act(async () => { button.click(); });
expect(openContextPreviewCalls).toEqual([{ directory: '/repo-worktree', url: 'http://localhost:4000/' }]);
});
test('replaced executions cannot be cleared by their still-open old stream', async () => {
await renderButton();
const button = host.querySelector('button');
if (!button) throw new Error('missing action button');
await act(async () => { button.click(); });
await act(async () => {
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'other-session', cwd: '/repo', status: 'running', createdAt: 2,
mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'new-execution' },
}]);
});
await act(async () => { emitToSession(firstSessionId(), { type: 'exit', sequence: 10 }); });
const tab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
expect(tab?.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: 'new-execution' });
expect(tab?.lifecycle).toBe('running');
});
test('runs, stops, and reruns on the same action tab while cleaning old subscriptions once', async () => {
await renderButton();
@@ -257,7 +358,7 @@ describe('ProjectActionsButton lifecycle', () => {
});
const firstTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
expect(firstTab?.terminalSessionId).toBe('session-1');
expect(firstTab?.terminalSessionId).toBe(firstSessionId());
expect(firstTab?.purpose.type).toBe('project-action');
const firstExecution = firstTab?.purpose.type === 'project-action' ? firstTab.purpose.executionId : null;
expect(firstExecution).not.toBeNull();
@@ -279,16 +380,16 @@ describe('ProjectActionsButton lifecycle', () => {
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?.terminalSessionId).toBe(secondSessionId());
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(sendCalls).toEqual([`${firstSessionId()}:\x03`]);
expect(forceKillCalls).toEqual([]);
expect(closeCalls).toEqual(['session-1']);
expect(closeCalls).toEqual([]);
expect(subscriptions.map((entry) => entry.closed)).toEqual([1, 1, 0]);
});
@@ -372,10 +473,10 @@ describe('ProjectActionsButton lifecycle', () => {
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');
expect(autoDiscoverTab?.terminalSessionId).toBe(firstSessionId());
await act(async () => {
emitToSession('session-1', {
emitToSession(firstSessionId(), {
type: 'data',
data: 'Ready at http://127.0.0.1:4321\n',
sequence: 1,
@@ -490,7 +591,7 @@ describe('ProjectActionsButton lifecycle', () => {
expect(openExternalCalls).toEqual([]);
await act(async () => {
emitToSession('session-1', {
emitToSession(firstSessionId(), {
type: 'data',
data: 'Server listening at http://127.0.0.1:4000\n',
sequence: 1,
@@ -42,6 +42,7 @@ import {
reconcileTerminalSessionAuthority,
stopProjectActionTerminalSession,
} from '@/lib/projectActionTerminal';
import { observeTerminalSessions } from '@/lib/terminalSessionObserver';
import type { TerminalTab } from '@/stores/useTerminalStore';
type UrlWatchEntry = {
@@ -350,13 +351,16 @@ export const ProjectActionsButton = ({
React.useEffect(() => {
const watchedDirectories = new Set(watchedTerminalDirectories);
for (const watch of Object.values(urlWatchByRunKeyRef.current)) {
if (!watchedDirectories.has(watch.directory)) clearExecutionUi(watch.directory, watch.actionId, watch.executionId);
}
for (const executionStateKey of Object.keys(streamCleanupByRunKeyRef.current)) {
const executionDirectory = executionStateKey.split('::', 1)[0] ?? '';
if (!watchedDirectories.has(executionDirectory)) {
closeTrackedSubscription(executionStateKey);
}
}
}, [closeTrackedSubscription, watchedTerminalDirectories]);
}, [clearExecutionUi, closeTrackedSubscription, watchedTerminalDirectories]);
const revealProjectActionTerminal = React.useCallback((hostDirectory: string, executionDirectory: string) => {
useUIStore.getState().openContextPanelTab(hostDirectory, {
@@ -391,23 +395,13 @@ export const ProjectActionsButton = ({
}, [loadActions]);
React.useEffect(() => {
if (!terminal.listSessions) {
return;
}
let cancelled = false;
for (const executionDirectory of watchedTerminalDirectories) {
void reconcileTerminalSessionAuthority(terminal, executionDirectory, {
captureStartedActionMutationRevisions,
}).then((result) => {
if (cancelled || !result) return;
reconcileServerSessions(executionDirectory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
});
}
return () => {
cancelled = true;
};
const cleanups = watchedTerminalDirectories.map(executionDirectory => observeTerminalSessions(
terminal, executionDirectory, captureStartedActionMutationRevisions,
result => reconcileServerSessions(executionDirectory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
}),
));
return () => { for (const close of cleanups) close(); };
}, [captureStartedActionMutationRevisions, reconcileServerSessions, terminal, watchedTerminalDirectories]);
React.useEffect(() => {
@@ -528,7 +522,7 @@ export const ProjectActionsButton = ({
actionId: entry.actionId,
executionId: entry.executionId,
lastSeenChunkId: null,
openedUrl: false,
openedUrl: true,
tail: '',
openInPreview: false,
announced: [],
@@ -734,6 +728,7 @@ export const ProjectActionsButton = ({
}
if (startingRunKeysRef.current.has(runKey)) return;
startingRunKeysRef.current.add(runKey);
let requestedExecution: { directory: string; tabId: string; id: string } | null = null;
try {
const discovered = action.id === AUTO_DISCOVER_ACTION_ID
@@ -760,7 +755,7 @@ export const ProjectActionsButton = ({
const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0;
const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID;
const launchContextHostDirectory = contextHostDirectoryRef.current || normalizedDirectory;
const { executionDirectory, key, tabId, sessionId } = await getOrCreateActionTab(discovered);
const { executionDirectory, key, tabId } = await getOrCreateActionTab(discovered);
const normalizedCommand = normalizeProjectActionCommand(discovered.command);
if (!normalizedCommand) {
throw new Error(t('projectActions.error.failedToRunAction'));
@@ -789,26 +784,23 @@ export const ProjectActionsButton = ({
}
const priorTab = getActionTab(executionDirectory, discovered.id);
const priorExecutionId = priorTab?.purpose.type === 'project-action' ? priorTab.purpose.executionId : null;
if (priorExecutionId) {
clearExecutionUi(executionDirectory, discovered.id, priorExecutionId);
}
const requestedExecutionId = allocateActionExecution(executionDirectory, tabId, discovered.id);
if (!requestedExecutionId) {
throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
}
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: requestedExecutionId });
let activeSessionId: string | null = null;
let adoptedExecutionId = requestedExecutionId;
try {
let activeSessionId: string;
let adoptedExecutionId: string;
if (priorTab?.lifecycle === 'running' && priorTab.terminalSessionId
&& priorTab.purpose.type === 'project-action' && priorTab.purpose.executionId) {
activeSessionId = priorTab.terminalSessionId;
adoptedExecutionId = priorTab.purpose.executionId;
} else {
const priorExecutionId = priorTab?.purpose.type === 'project-action' ? priorTab.purpose.executionId : null;
if (priorExecutionId) clearExecutionUi(executionDirectory, discovered.id, priorExecutionId);
const requestedExecutionId = allocateActionExecution(executionDirectory, tabId, discovered.id);
if (!requestedExecutionId) throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
requestedExecution = { directory: executionDirectory, tabId, id: requestedExecutionId };
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: requestedExecutionId });
const created = await createProjectActionTerminalSession({
terminal,
previousSessionId: sessionId,
createOptions: {
cwd: executionDirectory,
sessionId: tabId,
shell: terminalShell,
loginShell: terminalLoginShell,
themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
@@ -820,40 +812,41 @@ export const ProjectActionsButton = ({
purpose: { type: 'project-action', actionId: discovered.id, executionId: requestedExecutionId },
});
if (!matchesActionExecution(executionDirectory, tabId, requestedExecutionId)) {
await terminal.close(created.sessionId).catch(() => undefined);
if (created.sessionId === requestedExecutionId) await terminal.close(created.sessionId).catch(() => undefined);
return;
}
adoptedExecutionId = created.purpose?.type === 'project-action' ? created.purpose.executionId : requestedExecutionId;
setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: adoptedExecutionId });
activeSessionId = created.sessionId;
setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: adoptedExecutionId });
setTabSessionId(executionDirectory, tabId, activeSessionId, { expectedExecutionId: adoptedExecutionId });
setTabLifecycle(executionDirectory, tabId, 'running', { expectedExecutionId: adoptedExecutionId });
} finally {
setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
}
if (!activeSessionId) {
throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
}
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) {
try {
await terminal.close(activeSessionId);
} catch {
// noop
}
return;
}
if (revealTerminal && launchContextHostDirectory) {
revealProjectActionTerminal(launchContextHostDirectory, executionDirectory);
}
urlWatchByRunKeyRef.current[key] = {
hostDirectory: launchContextHostDirectory,
directory: executionDirectory,
tabId,
actionId: discovered.id,
executionId: adoptedExecutionId,
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
announced: [],
offering: false,
};
const executionStateKey = executionKey(executionDirectory, discovered.id, adoptedExecutionId);
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: adoptedExecutionId });
const subscription = terminal.connect(
activeSessionId,
{ onEvent: (event) => {
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
if (event.purpose?.type === 'project-action' && event.purpose.executionId !== adoptedExecutionId) return;
if (event.type === 'snapshot') {
useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0);
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
@@ -880,6 +873,7 @@ export const ProjectActionsButton = ({
clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId);
}
}, onError: (_error, fatal) => {
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
if (fatal) {
useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId });
@@ -893,6 +887,7 @@ export const ProjectActionsButton = ({
subscription.close();
return;
}
streamCleanupByRunKeyRef.current[executionStateKey]?.();
streamCleanupByRunKeyRef.current[executionStateKey] = subscription.close;
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]);
@@ -919,19 +914,6 @@ export const ProjectActionsButton = ({
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS);
}
urlWatchByRunKeyRef.current[key] = {
hostDirectory: launchContextHostDirectory,
directory: executionDirectory,
tabId,
actionId: discovered.id,
executionId: adoptedExecutionId,
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
announced: [],
offering: false,
};
if (desktopForwardUrl) {
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
@@ -939,7 +921,7 @@ export const ProjectActionsButton = ({
toast.success(t('projectActions.toast.openedForwardedUrl'));
} else if (manualOpenUrl) {
setTabPreviewUrl(executionDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true, expectedExecutionId: adoptedExecutionId });
openContextPreview(executionDirectory, manualOpenUrl);
openContextPreview(launchContextHostDirectory, manualOpenUrl);
toast.success(t('projectActions.toast.openedActionUrl'));
} else if (hasCustomOpenUrl) {
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
@@ -952,12 +934,11 @@ export const ProjectActionsButton = ({
}
} catch (error) {
const executionDirectory = executionDirectoryFor(action);
const currentTab = getActionTab(executionDirectory, action.id);
if (currentTab?.purpose.type === 'project-action' && currentTab.purpose.executionId) {
clearExecutionUi(executionDirectory, action.id, currentTab.purpose.executionId);
setTabLifecycle(executionDirectory, currentTab.id, 'exited', { expectedExecutionId: currentTab.purpose.executionId });
setTabPurpose(executionDirectory, currentTab.id, { type: 'project-action', actionId: action.id, executionId: null });
if (requestedExecution && matchesActionExecution(requestedExecution.directory, requestedExecution.tabId, requestedExecution.id)) {
const { directory: failedDirectory, tabId: failedTabId, id } = requestedExecution;
clearExecutionUi(failedDirectory, action.id, id);
setTabLifecycle(failedDirectory, failedTabId, 'exited', { expectedExecutionId: id });
setTabPurpose(failedDirectory, failedTabId, { type: 'project-action', actionId: action.id, executionId: null });
}
if (error instanceof Error && error.message === 'PROJECT_ACTION_RUN_CANCELLED') {
return;
@@ -335,6 +335,25 @@ describe('TerminalView project action tab indicator', () => {
expect(targetKey).toContain('/repo');
});
test('replacing an exited action does not show a connection failure', async () => {
const store = useTerminalStore.getState();
const actionTab = store.getDirectoryState('/repo')?.tabs.find(tab => tab.label === 'Build');
if (!actionTab) throw new Error('action tab missing');
store.setTabSessionId('/repo', actionTab.id, 'srv-build');
store.setActiveTab('/repo', actionTab.id);
connectBehavior = (_sessionId, handlers) => {
void Promise.resolve().then(() => {
handlers.onError?.(Object.assign(new Error('Terminal replaced by a new action run'), { code: 'SUPERSEDED' }), true);
});
return { close: () => undefined };
};
await act(async () => root.render(React.createElement(TerminalView, { visible: true })));
await flushEffects();
expect(host.textContent).not.toContain('terminalView.error.connectionFailed');
expect(store.getActiveTab('/repo')?.terminalSessionId).toBeNull();
expect(store.getActiveTab('/repo')?.lifecycle).toBe('exited');
});
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');
@@ -23,7 +23,7 @@ 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';
import { observeTerminalSessions } from '@/lib/terminalSessionObserver';
type TerminalViewProps = {
visible?: boolean;
@@ -200,29 +200,15 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
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.
// Only a visible panel requests discovery; failed reads preserve known state.
React.useEffect(() => {
if (!terminalHydrated || !terminalDirectory || !terminal.listSessions) {
return;
}
let cancelled = false;
const directory = terminalDirectory;
void reconcileTerminalSessionAuthority(terminal, directory, {
captureStartedActionMutationRevisions,
})
.then((result) => {
if (cancelled || directoryRef.current !== directory || !result) return;
reconcileServerSessions(directory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
if (!terminalHydrated || !terminalDirectory || !isTerminalVisible) return;
return observeTerminalSessions(terminal, terminalDirectory, captureStartedActionMutationRevisions, result => {
reconcileServerSessions(terminalDirectory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
return () => {
cancelled = true;
};
}, [captureStartedActionMutationRevisions, terminalHydrated, terminalDirectory, terminal, reconcileServerSessions]);
});
}, [captureStartedActionMutationRevisions, isTerminalVisible, terminalHydrated, terminalDirectory, terminal, reconcileServerSessions]);
React.useEffect(() => {
if (!showQuickKeys && activeModifier !== null) {
@@ -306,12 +292,15 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
// Mark active before connect so early events aren't dropped.
activeTerminalIdRef.current = terminalId;
const ownsStream = () => activeTerminalIdRef.current === terminalId
&& useTerminalStore.getState().getDirectoryState(directory)?.tabs
.some(tab => tab.id === tabId && tab.terminalSessionId === terminalId);
const subscription = terminal.connect(
terminalId,
{
onEvent: (event: TerminalStreamEvent) => {
if (activeTerminalIdRef.current !== terminalId) {
if (!ownsStream()) {
return;
}
@@ -375,7 +364,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
}
},
onError: (error, fatal) => {
if (activeTerminalIdRef.current !== terminalId) {
if (!ownsStream()) {
return;
}
@@ -398,10 +387,9 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
return;
}
}
setConnectionError(
t('terminalView.error.connectionFailed', { message: error.message })
);
setIsFatalError(true);
const superseded = error.code === 'SUPERSEDED';
setConnectionError(superseded ? null : t('terminalView.error.connectionFailed', { message: error.message }));
setIsFatalError(!superseded);
setConnecting(directory, tabId, false);
setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null);