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
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user