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
@@ -15,6 +15,7 @@ import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
import { useI18n } from '@/lib/i18n';
@@ -31,7 +32,7 @@ import {
toProjectActionRunKey,
} from '@/lib/projectActions';
import { detectDevServerCommand, readPackageJsonScripts } from '@/lib/detectDevServer';
import { connectTerminalStream } from '@/lib/terminalApi';
import { waitForTerminalExit } from '@/lib/projectActionTerminal';
type UrlWatchEntry = {
lastSeenChunkId: number | null;
@@ -40,12 +41,6 @@ type UrlWatchEntry = {
openInPreview: boolean;
};
const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
};
interface ProjectActionsButtonProps {
projectRef: ProjectRef | null;
directory: string;
@@ -154,6 +149,7 @@ export const ProjectActionsButton = ({
allowMobile = false,
}: ProjectActionsButtonProps) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const { terminal, runtime } = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
@@ -161,13 +157,14 @@ export const ProjectActionsButton = ({
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const terminalShell = useUIStore((state) => state.terminalShell);
const terminalLoginShell = useUIStore((state) => state.terminalLoginShells.includes(state.terminalShell));
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const terminalSessions = useTerminalStore((state) => state.sessions);
const ensureDirectory = useTerminalStore((state) => state.ensureDirectory);
const setTabLabel = useTerminalStore((state) => state.setTabLabel);
const setTabIconKey = useTerminalStore((state) => state.setTabIconKey);
@@ -187,6 +184,7 @@ export const ProjectActionsButton = ({
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
const streamCleanupByRunKeyRef = React.useRef<Record<string, () => void>>({});
const previewWaitTimeoutByRunKeyRef = React.useRef<Record<string, number>>({});
const startingRunKeysRef = React.useRef<Set<string>>(new Set());
const loadRequestIdRef = React.useRef(0);
const projectId = projectRef?.id ?? null;
@@ -311,79 +309,66 @@ export const ProjectActionsButton = ({
}, [actions, canUseAutoDiscover, selectedActionId]);
React.useEffect(() => {
for (const [key, entry] of Object.entries(projectActionRuns)) {
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || tab.terminalSessionId !== entry.sessionId) {
removeProjectActionRun(key);
}
}
}, [projectActionRuns, removeProjectActionRun, terminalSessions]);
React.useEffect(() => {
for (const [runKey, entry] of Object.entries(projectActionRuns)) {
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
if (!action) {
continue;
}
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) {
continue;
}
const nextChunks = tab.bufferChunks.filter((chunk) => {
if (watch.lastSeenChunkId === null) {
return true;
const monitorRuns = () => {
const terminalSessions = useTerminalStore.getState().sessions;
const currentRuns = useTerminalStore.getState().projectActionRuns;
for (const [runKey, entry] of Object.entries(currentRuns)) {
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || tab.terminalSessionId !== entry.sessionId) {
removeProjectActionRun(runKey);
continue;
}
return chunk.id > watch.lastSeenChunkId;
});
if (nextChunks.length === 0) {
continue;
}
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = displayActions.find((item) => item.id === entry.actionId);
if (!action || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) continue;
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
const nextChunks = tab.bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId);
if (nextChunks.length === 0) continue;
watch.lastSeenChunkId = lastChunkId;
watch.tail = textForScan.slice(-512);
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
if (maybeUrl) {
watch.openedUrl = true;
if (watch.openInPreview) {
const run = projectActionRuns[runKey];
if (run) {
setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false });
if (run.status === 'waiting-for-preview') {
updateProjectActionRunStatus(runKey, 'running');
watch.lastSeenChunkId = lastChunkId;
watch.tail = textForScan.slice(-512);
if (maybeUrl) {
watch.openedUrl = true;
if (watch.openInPreview) {
const run = currentRuns[runKey];
if (run) {
setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false });
if (run.status === 'waiting-for-preview') updateProjectActionRunStatus(runKey, 'running');
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
openContextPreview(run.directory, maybeUrl);
}
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
openContextPreview(run.directory, maybeUrl);
} else {
void openExternal(maybeUrl);
toast.success(t('projectActions.toast.openedUrlFromOutput'));
}
} else {
void openExternal(maybeUrl);
toast.success(t('projectActions.toast.openedUrlFromOutput'));
}
urlWatchByRunKeyRef.current[runKey] = watch;
}
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
if (!currentRuns[runKey]) {
delete urlWatchByRunKeyRef.current[runKey];
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
}
}
urlWatchByRunKeyRef.current[runKey] = watch;
}
};
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
if (!projectActionRuns[runKey]) {
delete urlWatchByRunKeyRef.current[runKey];
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
}
}
}, [displayActions, openContextPreview, openExternal, projectActionRuns, setTabPreviewUrl, t, terminalSessions, updateProjectActionRunStatus]);
monitorRuns();
return useTerminalStore.subscribe((state, previousState) => {
if (state.sessions !== previousState.sessions) monitorRuns();
});
}, [displayActions, openContextPreview, openExternal, projectActionRuns, removeProjectActionRun, setTabPreviewUrl, t, updateProjectActionRunStatus]);
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction, options: { revealTerminal?: boolean } = {}) => {
if (!normalizedDirectory) {
@@ -408,8 +393,8 @@ export const ProjectActionsButton = ({
setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`);
setTabIconKey(normalizedDirectory, tabId, action.icon || 'play');
setActiveTab(normalizedDirectory, tabId);
if (options.revealTerminal !== false) {
setActiveTab(normalizedDirectory, tabId);
setBottomTerminalOpen(true);
setActiveMainTab('terminal');
}
@@ -447,6 +432,8 @@ export const ProjectActionsButton = ({
if (existingRun && existingRun.status === 'running') {
return;
}
if (startingRunKeysRef.current.has(runKey)) return;
startingRunKeysRef.current.add(runKey);
try {
const discovered = action.id === AUTO_DISCOVER_ACTION_ID
@@ -471,16 +458,23 @@ export const ProjectActionsButton = ({
: action;
const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0;
const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal: !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID });
const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID;
const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal });
let activeSessionId = sessionId;
let createdSession = false;
if (!activeSessionId) {
setConnecting(normalizedDirectory, tabId, true);
try {
const created = await terminal.createSession({ cwd: normalizedDirectory });
const created = await terminal.createSession({
cwd: normalizedDirectory,
sessionId: tabId,
shell: terminalShell,
loginShell: terminalLoginShell,
themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
terminalBackground: currentTheme.colors.surface.background,
terminalForeground: currentTheme.colors.syntax.base.foreground,
});
activeSessionId = created.sessionId;
createdSession = true;
setTabSessionId(normalizedDirectory, tabId, activeSessionId);
} finally {
setConnecting(normalizedDirectory, tabId, false);
@@ -491,18 +485,17 @@ export const ProjectActionsButton = ({
throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
}
if (createdSession) {
await sleep(350);
}
if (discovered.id === AUTO_DISCOVER_ACTION_ID) {
streamCleanupByRunKeyRef.current[key]?.();
setConnecting(normalizedDirectory, tabId, true);
streamCleanupByRunKeyRef.current[key] = connectTerminalStream(
streamCleanupByRunKeyRef.current[key]?.();
setConnecting(normalizedDirectory, tabId, true);
const subscription = terminal.connect(
activeSessionId,
(event) => {
{ onEvent: (event) => {
if (event.type === 'snapshot') {
useTerminalStore.getState().replaceBuffer(normalizedDirectory, tabId, event.data ?? '', event.sequence ?? 0);
useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false);
}
if (event.type === 'data' && typeof event.data === 'string' && event.data.length > 0) {
useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data);
useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data, event.sequence, event.replayData);
}
if (event.type === 'exit') {
useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited');
@@ -514,13 +507,16 @@ export const ProjectActionsButton = ({
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]);
delete previewWaitTimeoutByRunKeyRef.current[key];
}
},
() => {
}, onError: (_error, fatal) => {
useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false);
},
{ maxRetries: 60, initialRetryDelay: 250, maxRetryDelay: 2000, connectionTimeout: 5000 },
if (fatal) {
useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited');
useTerminalStore.getState().setTabSessionId(normalizedDirectory, tabId, null);
useTerminalStore.getState().removeProjectActionRun(key);
}
} },
);
}
streamCleanupByRunKeyRef.current[key] = subscription.close;
const hasDesktopForwardSelection = discovered.autoOpenUrl === true
&& isDesktopShellApp
@@ -542,11 +538,27 @@ export const ProjectActionsButton = ({
delete previewWaitTimeoutByRunKeyRef.current[key];
if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) {
previewWaitTimeoutByRunKeyRef.current[key] = window.setTimeout(() => {
useTerminalStore.getState().updateProjectActionRunStatus(key, 'running');
const store = useTerminalStore.getState();
const run = store.projectActionRuns[key];
store.updateProjectActionRunStatus(key, 'running');
if (run) {
store.setActiveTab(run.directory, run.tabId);
useUIStore.getState().setBottomTerminalOpen(true);
}
delete previewWaitTimeoutByRunKeyRef.current[key];
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS);
}
urlWatchByRunKeyRef.current[key] = {
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
};
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`);
if (desktopForwardUrl) {
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true });
void openExternal(desktopForwardUrl);
@@ -565,15 +577,6 @@ export const ProjectActionsButton = ({
setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: false, autoOpened: false });
}
urlWatchByRunKeyRef.current[key] = {
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
};
const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n'));
await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`);
} catch (error) {
removeProjectActionRun(runKey);
delete urlWatchByRunKeyRef.current[runKey];
@@ -582,14 +585,21 @@ export const ProjectActionsButton = ({
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
delete previewWaitTimeoutByRunKeyRef.current[runKey];
toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction'));
} finally {
startingRunKeysRef.current.delete(runKey);
}
}, [
currentTheme.colors.surface.background,
currentTheme.colors.syntax.base.foreground,
currentTheme.metadata.variant,
desktopSshInstances,
getOrCreateActionTab,
allowMobile,
isMobile,
isDesktopShellApp,
normalizedDirectory,
terminalLoginShell,
terminalShell,
openExternal,
openContextPreview,
projectActionRuns,
@@ -613,22 +623,22 @@ export const ProjectActionsButton = ({
updateProjectActionRunStatus(runKey, 'stopping');
const exitPromise = waitForTerminalExit(terminal, activeRun.sessionId, 1000);
try {
await terminal.sendInput(activeRun.sessionId, '\x03');
} catch {
// noop
}
await new Promise((resolve) => {
window.setTimeout(resolve, 1000);
});
const exitObserved = await exitPromise;
const afterTab = useTerminalStore.getState().getDirectoryState(activeRun.directory)?.tabs
.find((entry) => entry.id === activeRun.tabId);
const sessionStillSame = afterTab?.terminalSessionId === activeRun.sessionId;
if (sessionStillSame) {
if (sessionStillSame && !exitObserved) {
if (typeof terminal.forceKill === 'function') {
try {
await terminal.forceKill({ sessionId: activeRun.sessionId });
@@ -699,6 +709,13 @@ export const ProjectActionsButton = ({
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]);
const previewAction = selectedAction ?? displayActions[0] ?? null;
const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(normalizedDirectory, previewAction.id)] : null;
const selectedRunPreviewUrl = useTerminalStore((state) => {
if (!previewRun) return null;
return state.sessions.get(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null;
});
if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) {
return null;
}
@@ -716,9 +733,6 @@ export const ProjectActionsButton = ({
const selectedRunning = projectActionRuns[selectedRunKey];
const isStoppingSelected = selectedRunning?.status === 'stopping';
const isWaitingForSelectedPreview = selectedRunning?.status === 'waiting-for-preview';
const selectedRunPreviewUrl = selectedRunning
? terminalSessions.get(selectedRunning.directory)?.tabs.find((tab) => tab.id === selectedRunning.tabId)?.previewUrl ?? null
: null;
const showSelectedPreviewButton = Boolean(selectedRunning && selectedRunPreviewUrl);
const handleOpenSelectedPreview = () => {
if (!selectedRunning || !selectedRunPreviewUrl) {