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]);
+4 -4
View File
@@ -64,7 +64,6 @@ import type { GitHubAuthStatus } from '@/lib/api/types';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
import { forceKillTerminal } from '@/lib/terminalApi';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown';
@@ -1776,7 +1775,8 @@ export const Header: React.FC<HeaderProps> = ({
}, [shortcutOverrides]);
useEffect(() => {
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) {
// Project actions may intentionally promote the terminal to the desktop main view.
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) {
setActiveMainTab('chat');
}
}, [activeMainTab, isMobile, setActiveMainTab]);
@@ -1831,7 +1831,7 @@ export const Header: React.FC<HeaderProps> = ({
try {
// Ensure preview/dev terminals don't linger.
await forceKillTerminal({});
await runtimeApis.terminal.forceKill?.({});
} catch {
// ignore
}
@@ -1856,7 +1856,7 @@ export const Header: React.FC<HeaderProps> = ({
setIsDevShutdownInFlight(false);
}
}
}, [isDevShutdownInFlight, setIsDesktopServicesOpen]);
}, [isDevShutdownInFlight, runtimeApis.terminal, setIsDesktopServicesOpen]);
const quotaDisplayTabs = React.useMemo(() => {
return [
@@ -16,6 +16,7 @@ import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun';
import { TerminalView } from '@/components/views/TerminalView';
import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore';
@@ -31,8 +32,9 @@ import { FilesView } from '@/components/views/FilesView';
import { GitView } from '@/components/views/GitView';
import { PlanView } from '@/components/views/PlanView';
// Heavy views loaded on-demand to reduce initial bundle parse time.
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
// suspending here leaves a large blank panel on slower machines.
// Other heavy views stay on-demand to reduce initial bundle parse time.
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
@@ -365,7 +367,7 @@ export const MainLayout: React.FC = () => {
case 'diff':
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
case 'terminal':
return <React.Suspense fallback={null}><TerminalView /></React.Suspense>;
return <TerminalView />;
case 'files':
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
case 'context':
@@ -539,12 +541,10 @@ export const MainLayout: React.FC = () => {
<ContextPanel />
</div>
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
{isBottomTerminalOpen ? (
<BottomTerminalDock isOpen={isBottomTerminalOpen && activeMainTab !== 'terminal'} isMobile={isMobile}>
{isBottomTerminalOpen && activeMainTab !== 'terminal' ? (
<ErrorBoundary>
<React.Suspense fallback={null}>
<TerminalView />
</React.Suspense>
<TerminalView />
</ErrorBoundary>
) : null}
</BottomTerminalDock>
@@ -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) {