fix(terminal): reconcile project action executions across clients (#3362)
This commit is contained in:
committed by
GitHub
parent
4e0eed717d
commit
d37ce34a2e
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user