feat(terminal): refactor runtime and add mobile workspace (#2280)
Replace the legacy terminal flow with a shared authenticated WebSocket runtime used across web, desktop, relay, and mobile surfaces. - introduce the v3 terminal protocol with scoped attachments, snapshots, ordered output, bounded replay history, reconnects, and explicit lifecycle - harden PTY creation, restart, resize, close, force-kill, idle cleanup, shell selection, login mode, environment sanitization, and appearance sync - add runtime-aware terminal APIs with relay authentication and Electron parity - add a fullscreen mobile terminal workspace with touch scrolling, long-press selection, safe-area controls, quick keys, and Ctrl/Alt input - add terminal selection attachments, preview detection, project actions, shell settings, and localized UI - harden Ghostty rendering, resize recovery, Unicode handling, block characters, line height, and stale-row behavior - remove the obsolete terminal SSE path and update reverse-proxy guidance - expand terminal runtime, transport, input, selection, and store coverage - avoid duplicate web builds when preparing mobile assets in root CI builds
This commit is contained in:
committed by
GitHub
parent
f5b4a267c0
commit
d4a8c4d2e1
@@ -3,11 +3,15 @@ import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import type { RuntimeAPIs, SettingsPayload } from '@/lib/api/types';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { applyPersistedHomeDirectoryToWindow, syncDesktopSettings, updateDesktopSettings } from './persistence';
|
||||
import { applyPersistedHomeDirectoryToWindow, invalidateSettingsCache, syncDesktopSettings, updateDesktopSettings } from './persistence';
|
||||
import { switchRuntimeEndpoint } from './runtime-switch';
|
||||
|
||||
type TestWindow = {
|
||||
__OPENCHAMBER_HOME__?: string;
|
||||
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
|
||||
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
|
||||
dispatchEvent: (event: Event) => boolean;
|
||||
setTimeout: typeof setTimeout;
|
||||
clearTimeout: typeof clearTimeout;
|
||||
@@ -51,6 +55,12 @@ const getWindow = (): TestWindow => {
|
||||
createdWindow = true;
|
||||
}
|
||||
const testWindow = window as unknown as Partial<TestWindow>;
|
||||
if (!testWindow.addEventListener || !testWindow.removeEventListener) {
|
||||
const eventTarget = new EventTarget();
|
||||
testWindow.addEventListener = eventTarget.addEventListener.bind(eventTarget);
|
||||
testWindow.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
|
||||
testWindow.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
|
||||
}
|
||||
testWindow.dispatchEvent ??= () => true;
|
||||
testWindow.setTimeout ??= setTimeout;
|
||||
testWindow.clearTimeout ??= clearTimeout;
|
||||
@@ -59,6 +69,15 @@ const getWindow = (): TestWindow => {
|
||||
};
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const registerSettingsApi = (
|
||||
save: (changes: Partial<SettingsPayload>) => Promise<SettingsPayload>,
|
||||
@@ -124,6 +143,7 @@ describe('updateDesktopSettings', () => {
|
||||
beforeEach(() => {
|
||||
getWindow();
|
||||
registerRuntimeAPIs(null);
|
||||
invalidateSettingsCache();
|
||||
resetModelPrefsState();
|
||||
});
|
||||
|
||||
@@ -196,6 +216,82 @@ describe('updateDesktopSettings', () => {
|
||||
expect(secondResolved).toBe(true);
|
||||
});
|
||||
|
||||
test('drains a pending save to the previous runtime and ignores its stale response', async () => {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://settings-a.example', runtimeKey: 'settings-a' });
|
||||
const saveResult = deferred<SettingsPayload>();
|
||||
const saveCalls: Array<Partial<SettingsPayload>> = [];
|
||||
registerSettingsSave((changes) => {
|
||||
saveCalls.push(changes);
|
||||
return saveResult.promise;
|
||||
});
|
||||
const update = updateDesktopSettings({ terminalShell: 'zsh' });
|
||||
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://settings-b.example', runtimeKey: 'settings-b' });
|
||||
registerSettingsSave(async (changes) => changes as SettingsPayload);
|
||||
useUIStore.getState().setTerminalShell('fish');
|
||||
|
||||
expect(saveCalls).toEqual([{ terminalShell: 'zsh' }]);
|
||||
saveResult.resolve({ terminalShell: 'zsh' });
|
||||
await update;
|
||||
|
||||
expect(useUIStore.getState().terminalShell).toBe('fish');
|
||||
});
|
||||
|
||||
test('does not retry a failed old-runtime save against the new runtime', async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const fallbackRequests: string[] = [];
|
||||
const saveResult = deferred<SettingsPayload>();
|
||||
try {
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (init?.method === 'PUT' && url.includes('/api/config/settings')) fallbackRequests.push(url);
|
||||
return new Response(null, { status: 404 });
|
||||
}) as typeof fetch;
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://failed-save-a.example', runtimeKey: 'failed-save-a' });
|
||||
registerSettingsSave(() => saveResult.promise);
|
||||
const update = updateDesktopSettings({ terminalShell: 'zsh' });
|
||||
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://failed-save-b.example', runtimeKey: 'failed-save-b' });
|
||||
registerSettingsSave(async (changes) => changes as SettingsPayload);
|
||||
saveResult.reject(new Error('runtime A disconnected'));
|
||||
await update;
|
||||
|
||||
expect(fallbackRequests).toEqual([]);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects stale loads by generation across an A to B to A switch', async () => {
|
||||
const originalLoad = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://load-a.example', runtimeKey: 'load-a' });
|
||||
registerSettingsApi(async () => ({}), () => originalLoad.promise);
|
||||
const firstSync = syncDesktopSettings();
|
||||
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://load-b.example', runtimeKey: 'load-b' });
|
||||
registerSettingsApi(async () => ({}), async () => ({
|
||||
settings: { terminalShell: 'fish', draftStartersCraftGoalAdded: true },
|
||||
source: 'web',
|
||||
}));
|
||||
await syncDesktopSettings();
|
||||
expect(useUIStore.getState().terminalShell).toBe('fish');
|
||||
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://load-a.example', runtimeKey: 'load-a' });
|
||||
registerSettingsApi(async () => ({}), async () => ({
|
||||
settings: { terminalShell: 'bash', draftStartersCraftGoalAdded: true },
|
||||
source: 'web',
|
||||
}));
|
||||
await syncDesktopSettings();
|
||||
expect(useUIStore.getState().terminalShell).toBe('bash');
|
||||
|
||||
originalLoad.resolve({
|
||||
settings: { terminalShell: 'zsh', draftStartersCraftGoalAdded: true },
|
||||
source: 'web',
|
||||
});
|
||||
await firstSync;
|
||||
expect(useUIStore.getState().terminalShell).toBe('bash');
|
||||
});
|
||||
|
||||
test('applies model selector settings from server settings', async () => {
|
||||
getWindow();
|
||||
const settings = {
|
||||
@@ -220,6 +316,22 @@ describe('updateDesktopSettings', () => {
|
||||
expect(state.recentEfforts).toEqual(settings.recentEfforts);
|
||||
});
|
||||
|
||||
test('applies the persisted terminal shell from server settings', async () => {
|
||||
getWindow();
|
||||
invalidateSettingsCache();
|
||||
useUIStore.getState().setTerminalShell('auto');
|
||||
useUIStore.getState().setTerminalLoginShells([]);
|
||||
registerSettingsApi(async () => ({}), async () => ({
|
||||
settings: { terminalShell: 'zsh', terminalLoginShells: ['zsh', 'fish'] },
|
||||
source: 'web',
|
||||
}));
|
||||
|
||||
await syncDesktopSettings();
|
||||
|
||||
expect(useUIStore.getState().terminalShell).toBe('zsh');
|
||||
expect(useUIStore.getState().terminalLoginShells).toEqual(['zsh', 'fish']);
|
||||
});
|
||||
|
||||
test('autosaves all model selector settings fields', async () => {
|
||||
getWindow();
|
||||
const saveCalls: Array<Partial<SettingsPayload>> = [];
|
||||
@@ -256,4 +368,23 @@ describe('updateDesktopSettings', () => {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('autosaves terminal shell changes to shared settings', async () => {
|
||||
getWindow();
|
||||
useUIStore.getState().setTerminalShell('auto');
|
||||
useUIStore.getState().setTerminalLoginShells([]);
|
||||
const saveCalls: Array<Partial<SettingsPayload>> = [];
|
||||
registerSettingsSave(async (changes) => {
|
||||
saveCalls.push(changes);
|
||||
return changes as SettingsPayload;
|
||||
});
|
||||
startAppearanceAutoSave();
|
||||
|
||||
useUIStore.getState().setTerminalShell('zsh');
|
||||
useUIStore.getState().setTerminalLoginShells(['zsh']);
|
||||
await delay(500);
|
||||
|
||||
expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true);
|
||||
expect(saveCalls.some((changes) => changes.terminalLoginShells?.includes('zsh'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user