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
@@ -25,6 +25,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getPreviewTargetRecoveryAction } from '@/lib/preview/proxy-response';
import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
import { invokeDesktopCommand } from '@/lib/desktopNative';
@@ -951,27 +952,37 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
// Out-of-band upstream probe: iframes don't expose HTTP status to the parent,
// so when the proxy returns a 502 (upstream dev server is offline) the iframe
// would just render the raw JSON error body. Probe the proxy URL with a HEAD
// would just render the raw JSON error body. Probe the proxy URL with a GET
// request and surface a friendly overlay when the upstream is unreachable.
type UpstreamState = 'unknown' | 'starting' | 'reachable' | 'unreachable';
const [upstreamState, setUpstreamState] = React.useState<UpstreamState>('unknown');
const upstreamProbeStartedAtRef = React.useRef<number>(0);
const upstreamProbeAttemptRef = React.useRef<number>(0);
const upstreamProbeKeyRef = React.useRef<string>('');
const proxyRecoveryAttemptedKeyRef = React.useRef<string>('');
const PREVIEW_STARTUP_GRACE_MS = 15_000;
React.useEffect(() => {
if (!proxySrc) {
setUpstreamState('unknown');
upstreamProbeKeyRef.current = '';
upstreamProbeStartedAtRef.current = 0;
upstreamProbeAttemptRef.current = 0;
return;
}
let cancelled = false;
if (!upstreamProbeStartedAtRef.current) {
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
if (upstreamProbeKeyRef.current !== proxyCacheKey) {
upstreamProbeKeyRef.current = proxyCacheKey;
upstreamProbeStartedAtRef.current = Date.now();
upstreamProbeAttemptRef.current = 0;
}
const scheduleRetry = (delay: number) => {
retryTimeout = setTimeout(() => {
if (!cancelled) bumpReload();
}, delay);
};
setUpstreamState('unknown');
void (async () => {
@@ -993,21 +1004,36 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
if (cancelled) return;
if (!response) {
// Network-level failure (e.g. server itself is down) — treat as unreachable.
setUpstreamState('unreachable');
scheduleRetry(5000);
return;
}
if (response.status === 403 || response.status === 404) {
const recoveryAction = getPreviewTargetRecoveryAction(
response.headers,
proxyRecoveryAttemptedKeyRef.current === proxyCacheKey,
);
if (recoveryAction !== 'none') {
previewProxyTargetCache.delete(proxyCacheKey);
setProxyState({ status: 'loading' });
bumpProxyRegistration();
if (recoveryAction === 'retry-registration') {
proxyRecoveryAttemptedKeyRef.current = proxyCacheKey;
setProxyState({ status: 'loading' });
bumpProxyRegistration();
} else {
const errorBody = await response.json().catch(() => ({}));
if (cancelled) return;
const message = typeof errorBody?.error === 'string'
? errorBody.error
: `HTTP ${response.status}`;
setProxyState({ status: 'error', message });
}
return;
}
// The proxy emits 502 when the upstream is unreachable. Anything else
// (including 4xx from the upstream) means the upstream answered.
if (response.status !== 502) {
proxyRecoveryAttemptedKeyRef.current = '';
setUpstreamState('reachable');
return;
}
@@ -1021,19 +1047,17 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
upstreamProbeAttemptRef.current += 1;
const attempt = upstreamProbeAttemptRef.current;
const delay = Math.min(2000, 250 * Math.pow(2, Math.min(4, attempt)));
setTimeout(() => {
if (!cancelled) {
bumpReload();
}
}, delay).unref?.();
scheduleRetry(delay);
return;
}
setUpstreamState('unreachable');
scheduleRetry(5000);
})();
return () => {
cancelled = true;
if (retryTimeout) clearTimeout(retryTimeout);
};
}, [proxyCacheKey, proxySrc, reloadNonce]);