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:
Bohdan Triapitsyn
2026-07-17 13:17:21 +03:00
committed by GitHub
parent f5b4a267c0
commit d4a8c4d2e1
103 changed files with 4085 additions and 4496 deletions
+132 -1
View File
@@ -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);
});
});