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
+18 -1
View File
@@ -17,6 +17,7 @@ export type RuntimeEndpointChangedDetail = {
};
const RUNTIME_ENDPOINT_CHANGED_EVENT = 'openchamber:runtime-endpoint-changed';
const RUNTIME_ENDPOINT_WILL_CHANGE_EVENT = 'openchamber:runtime-endpoint-will-change';
let activeApiBaseUrl = '';
let activeRuntimeKey = '';
@@ -43,7 +44,10 @@ const normalizeRuntimeUrlKey = (value: string): string => {
const url = new URL(value);
url.hash = '';
url.search = '';
// Normalise pathname so root `/` becomes empty and no path ends with `/`.
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
// url.toString() still appends `/` when pathname is `/`; strip it
// so every key uses the bare-origin form: `url:https://example.com`.
return `url:${url.toString().replace(/\/+$/, '')}`;
} catch {
return `url:${value.trim().replace(/\/+$/, '') || 'default'}`;
@@ -98,6 +102,10 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken
const previousApiBaseUrl = getRuntimeApiBaseUrl();
const previousRuntimeKey = getRuntimeKey();
const runtimeKey = options.runtimeKey?.trim() || normalizeRuntimeUrlKey(apiBaseUrl);
const detail = { apiBaseUrl, previousApiBaseUrl, runtimeKey, previousRuntimeKey };
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<RuntimeEndpointChangedDetail>(RUNTIME_ENDPOINT_WILL_CHANGE_EVENT, { detail }));
}
activeApiBaseUrl = apiBaseUrl;
activeRuntimeKey = runtimeKey;
if (typeof window !== 'undefined') {
@@ -124,11 +132,20 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken
void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {});
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<RuntimeEndpointChangedDetail>(RUNTIME_ENDPOINT_CHANGED_EVENT, {
detail: { apiBaseUrl, previousApiBaseUrl, runtimeKey, previousRuntimeKey },
detail,
}));
}
};
export const subscribeRuntimeEndpointWillChange = (callback: (detail: RuntimeEndpointChangedDetail) => void): (() => void) => {
if (typeof window === 'undefined') return () => {};
const listener = (event: Event) => {
callback((event as CustomEvent<RuntimeEndpointChangedDetail>).detail);
};
window.addEventListener(RUNTIME_ENDPOINT_WILL_CHANGE_EVENT, listener);
return () => window.removeEventListener(RUNTIME_ENDPOINT_WILL_CHANGE_EVENT, listener);
};
export const subscribeRuntimeEndpointChanged = (callback: (detail: RuntimeEndpointChangedDetail) => void): (() => void) => {
if (typeof window === 'undefined') return () => {};
const listener = (event: Event) => {