Fix(mobile) terminal replay, reset artifacts, and preview detection (#1383)

* fix terminal rendering and preview detection

* Fix bot comments

* fix: protect terminal preview URL probe

---------

Co-authored-by: Konstantin Zolin <zolin_ka@vk.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
kostazol
2026-05-23 13:15:16 +03:00
committed by GitHub
co-authored by Konstantin Zolin Bohdan Triapitsyn
parent ca33c6ae57
commit 1d36995c47
9 changed files with 337 additions and 89 deletions
@@ -17,6 +17,7 @@ import { Icon } from "@/components/icon/Icon";
import { useDeviceInfo } from '@/lib/device';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { primeTerminalInputTransport } from '@/lib/terminalApi';
import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview';
import { useI18n } from '@/lib/i18n';
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
@@ -113,6 +114,8 @@ export const TerminalView: React.FC = () => {
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
const setConnecting = useTerminalStore((s) => s.setConnecting);
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
const setTabPreviewUrl = useTerminalStore((s) => s.setTabPreviewUrl);
const clearBuffer = useTerminalStore((s) => s.clearBuffer);
const openContextPreview = useUIStore((state) => state.openContextPreview);
@@ -162,6 +165,7 @@ export const TerminalView: React.FC = () => {
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const [viewportSizeVersion, setViewportSizeVersion] = React.useState(0);
const streamCleanupRef = React.useRef<(() => void) | null>(null);
const activeTerminalIdRef = React.useRef<string | null>(null);
@@ -174,6 +178,15 @@ export const TerminalView: React.FC = () => {
const nudgeOnConnectTerminalIdRef = React.useRef<string | null>(null);
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
const rehydratedSnapshotTakenRef = React.useRef(false);
const previewScanTailRef = React.useRef('');
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
const previewProbeGenerationRef = React.useRef(0);
const resetTerminalPreviewScan = React.useCallback(() => {
previewScanTailRef.current = '';
pendingPreviewProbeUrlsRef.current.clear();
previewProbeGenerationRef.current += 1;
}, []);
const focusTerminalWhenWindowActive = React.useCallback(() => {
if (useTouchTerminalInput) {
@@ -245,7 +258,8 @@ export const TerminalView: React.FC = () => {
React.useEffect(() => {
activeTabIdRef.current = activeTabId;
}, [activeTabId]);
resetTerminalPreviewScan();
}, [activeTabId, resetTerminalPreviewScan]);
React.useEffect(() => {
directoryRef.current = effectiveDirectory;
@@ -278,6 +292,47 @@ export const TerminalView: React.FC = () => {
[disconnectStream]
);
const scanTerminalPreviewOutput = React.useCallback(
(directory: string, tabId: string, data: string) => {
if (!data) {
return;
}
const combined = `${previewScanTailRef.current}${data}`.replace(/\r\n|\r/g, '\n');
const lines = combined.split('\n');
const completeText = combined.endsWith('\n')
? lines.join('\n')
: lines.slice(0, -1).join('\n');
previewScanTailRef.current = combined.endsWith('\n') ? '' : (lines[lines.length - 1] ?? '').slice(-1024);
if (!completeText) {
return;
}
const candidate = extractTerminalPreviewUrl(completeText);
if (!candidate || pendingPreviewProbeUrlsRef.current.has(candidate)) {
return;
}
const probeGeneration = previewProbeGenerationRef.current;
pendingPreviewProbeUrlsRef.current.add(candidate);
void isTerminalPreviewUrlAvailable(candidate).then((available) => {
pendingPreviewProbeUrlsRef.current.delete(candidate);
if (!available || previewProbeGenerationRef.current !== probeGeneration) {
return;
}
const currentTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((tab) => tab.id === tabId);
if (!currentTab || currentTab.previewUrlLocked || currentTab.previewUrl === candidate) {
return;
}
setTabPreviewUrl(directory, tabId, candidate, { locked: false, autoOpened: false });
});
},
[setTabPreviewUrl]
);
const startStream = React.useCallback(
(
directory: string,
@@ -330,6 +385,7 @@ export const TerminalView: React.FC = () => {
case 'data': {
if (event.data) {
appendToBuffer(directory, tabId, event.data);
scanTerminalPreviewOutput(directory, tabId, event.data);
}
break;
}
@@ -383,6 +439,7 @@ export const TerminalView: React.FC = () => {
);
setIsFatalError(true);
setConnecting(directory, tabId, false);
clearBuffer(directory, tabId);
setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null);
disconnectStream();
@@ -398,8 +455,10 @@ export const TerminalView: React.FC = () => {
},
[
appendToBuffer,
clearBuffer,
disconnectStream,
focusTerminalWhenWindowActive,
scanTerminalPreviewOutput,
setConnecting,
setTabLifecycle,
setTabSessionId,
@@ -469,12 +528,16 @@ export const TerminalView: React.FC = () => {
return;
}
const size = lastViewportSizeRef.current;
if (!size && isTerminalVisibleRef.current) {
return;
}
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
setConnecting(directory, tabId, true);
try {
const size = lastViewportSizeRef.current;
const session = await terminal.createSession({
cwd: directory,
cols: size?.cols,
@@ -543,6 +606,7 @@ export const TerminalView: React.FC = () => {
terminalLifecycle,
activeTabId,
hasOpenedTerminalViewport,
viewportSizeVersion,
enableTabs,
terminalHydrated,
ensureDirectory,
@@ -590,6 +654,8 @@ export const TerminalView: React.FC = () => {
setIsReconnectPending(false);
disconnectStream();
clearBuffer(effectiveDirectory, tabId);
resetTerminalPreviewScan();
try {
await closeTab(effectiveDirectory, tabId);
@@ -602,7 +668,7 @@ export const TerminalView: React.FC = () => {
} finally {
setIsRestarting(false);
}
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, t]);
}, [activeTabId, clearBuffer, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting, resetTerminalPreviewScan, t]);
const handleHardRestart = React.useCallback(async () => {
// Keep semantics: “close tab -> new clean tab”.
@@ -692,7 +758,11 @@ export const TerminalView: React.FC = () => {
const handleViewportResize = React.useCallback(
(cols: number, rows: number) => {
lastViewportSizeRef.current = { cols, rows };
const previous = lastViewportSizeRef.current;
if (!previous || previous.cols !== cols || previous.rows !== rows) {
lastViewportSizeRef.current = { cols, rows };
setViewportSizeVersion((version) => version + 1);
}
if (!isTerminalVisibleRef.current) {
return;
}
@@ -1112,6 +1182,7 @@ export const TerminalView: React.FC = () => {
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
{shouldRenderViewport ? (
<TerminalViewport
key={terminalViewportKey}
ref={(controller) => {
terminalControllerRef.current = controller;
}}
+23 -2
View File
@@ -48,7 +48,10 @@ type TerminalControlMessage = {
t: string;
s?: string;
c?: string;
d?: string;
f?: boolean;
i?: number;
r?: number;
v?: number;
exitCode?: number;
signal?: number | null;
@@ -152,6 +155,7 @@ class TerminalTransportManager {
private closed = false;
private subscriptions = new Map<symbol, StreamSubscription>();
private activeSubscriptionToken: symbol | null = null;
private replayCursorBySession = new Map<string, number>();
configure(socketUrl: string): void {
if (!socketUrl) {
@@ -233,7 +237,7 @@ class TerminalTransportManager {
try {
if (this.boundSessionId !== sessionId) {
this.requestedSessionId = sessionId;
socket.send(encodeControlFrame({ t: 'b', s: sessionId, v: 2 }));
socket.send(encodeControlFrame({ t: 'b', s: sessionId, r: this.replayCursorBySession.get(sessionId) ?? 0, v: 2 }));
}
socket.send(data);
return true;
@@ -265,6 +269,7 @@ class TerminalTransportManager {
this.socketUrl = '';
this.subscriptions.clear();
this.activeSubscriptionToken = null;
this.replayCursorBySession.clear();
}
prime(): void {
@@ -438,7 +443,7 @@ class TerminalTransportManager {
this.requestedSessionId = activeSubscription.sessionId;
try {
this.socket.send(encodeControlFrame({ t: 'b', s: activeSubscription.sessionId, v: 2 }));
this.socket.send(encodeControlFrame({ t: 'b', s: activeSubscription.sessionId, r: this.replayCursorBySession.get(activeSubscription.sessionId) ?? 0, v: 2 }));
} catch {
this.handleSocketFailure(new Error('Terminal websocket bind failed'));
}
@@ -569,6 +574,21 @@ class TerminalTransportManager {
return;
case 'po':
return;
case 'd': {
const sessionId = payload.s ?? this.boundSessionId ?? this.requestedSessionId;
if (!activeSubscription || !sessionId || sessionId !== activeSubscription.sessionId) {
return;
}
if (typeof payload.i === 'number' && Number.isFinite(payload.i)) {
this.replayCursorBySession.set(sessionId, Math.max(this.replayCursorBySession.get(sessionId) ?? 0, Math.trunc(payload.i)));
}
if (typeof payload.d === 'string' && payload.d.length > 0) {
activeSubscription.onEvent({ type: 'data', data: payload.d });
}
return;
}
case 'bok': {
this.boundSessionId = payload.s ?? this.requestedSessionId;
if (!activeSubscription) {
@@ -598,6 +618,7 @@ class TerminalTransportManager {
this.clearConnectionTimeout(activeSubscription);
this.boundSessionId = null;
this.requestedSessionId = null;
this.replayCursorBySession.delete(activeSubscription.sessionId);
activeSubscription.onEvent({
type: 'exit',
exitCode: payload.exitCode,
+115
View File
@@ -0,0 +1,115 @@
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
const LOOPBACK_URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[^\s<>'"`]*)?)/gi;
const PREVIEW_OUTPUT_PATTERN = /(?:➜\s*(?:Local|Network):)|\b(?:local|network|loopback|serving|listening|available|ready|started|running|server|vite|webpack|next\.js|astro|sveltekit|nuxt)\b/i;
const PYTHON_HTTP_SERVER_PATTERN = /Serving HTTP on .*? port (\d{2,5})/i;
const TRAILING_PUNCT = new Set(['.', ',', ';', ':', '!', '?']);
const trimUrlTrailingPunctuation = (url: string): string => {
let result = url;
while (result.length > 0) {
const last = result[result.length - 1];
if (last === ')' || last === ']' || last === '}' || last === '>') {
const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : '<';
const head = result.slice(0, -1);
const opens = (head.match(new RegExp(`\\${opener}`, 'g')) || []).length;
const closes = (head.match(new RegExp(`\\${last}`, 'g')) || []).length;
if (opens > closes) break;
result = head;
continue;
}
if (TRAILING_PUNCT.has(last)) {
result = result.slice(0, -1);
continue;
}
break;
}
return result;
};
const normalizeLoopbackUrl = (url: string): string => {
let normalized = trimUrlTrailingPunctuation(url);
normalized = normalized.replace('0.0.0.0', '127.0.0.1');
normalized = normalized.replace('[::1]', '127.0.0.1');
normalized = normalized.replace('[::]', '127.0.0.1');
return normalized;
};
export const extractTerminalPreviewUrl = (text: string): string | null => {
if (!text) return null;
const cleaned = text.replace(ANSI_ESCAPE_PATTERN, '');
const pythonMatch = cleaned.match(PYTHON_HTTP_SERVER_PATTERN);
if (pythonMatch?.[1]) {
const port = Number.parseInt(pythonMatch[1], 10);
if (Number.isFinite(port) && port > 0 && port <= 65535) {
return `http://127.0.0.1:${port}/`;
}
}
const lines = cleaned.split('\n');
for (const line of lines) {
if (!PREVIEW_OUTPUT_PATTERN.test(line)) {
continue;
}
const matches = Array.from(line.matchAll(LOOPBACK_URL_PATTERN));
if (matches.length === 0) {
continue;
}
const withPort = matches.find((match) => {
try {
return Boolean(new URL(normalizeLoopbackUrl(match[1])).port);
} catch {
return false;
}
});
return normalizeLoopbackUrl((withPort ?? matches[0])[1]);
}
return null;
};
export const isTerminalPreviewUrlAvailable = async (url: string, timeoutMs = 1500): Promise<boolean> => {
if (!url) return false;
if (typeof window === 'undefined') return false;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const host = parsed.hostname.toLowerCase();
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '::') {
return false;
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch('/api/system/probe-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: parsed.toString() }),
cache: 'no-store',
signal: controller.signal,
});
if (!response.ok) {
return false;
}
const result = await response.json().catch(() => null) as { ok?: unknown } | null;
return result?.ok === true;
} catch {
return false;
} finally {
window.clearTimeout(timeout);
}
};
@@ -121,67 +121,6 @@ const createEmptyTab = (id: string, label: string): TerminalTab => ({
previewUrlLocked: false,
});
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
// Many dev servers print loopback as 0.0.0.0, localhost, or IPv6 ([::]/[::1]).
const URL_PATTERN = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[(?:::1|::)\])(?::\d{2,5})?(?:\/[\w\-./~%!$&'()*+,;=:@?#[\]]*)?)/i;
// Dev server logs frequently wrap URLs in punctuation, e.g.
// "Local: http://localhost:5173/ (press h to show help)"
// "Serving on (http://127.0.0.1:50028/)."
// The URL_PATTERN above intentionally allows sub-delim characters like `()`
// in the path (RFC 3986), which means greedy capture can swallow trailing
// closing brackets that were really part of the surrounding sentence.
// Peel off any trailing closer that has no matching opener inside the URL,
// plus common trailing sentence punctuation.
const TRAILING_PUNCT = new Set(['.', ',', ';', ':', '!', '?']);
const trimUrlTrailingPunctuation = (url: string): string => {
let result = url;
while (result.length > 0) {
const last = result[result.length - 1];
if (last === ')' || last === ']' || last === '}' || last === '>') {
const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : '<';
// Count matched pairs in the rest of the URL; if there's no unmatched
// opener, the closer is from surrounding text — strip it.
const head = result.slice(0, -1);
const opens = (head.match(new RegExp(`\\${opener}`, 'g')) || []).length;
const closes = (head.match(new RegExp(`\\${last}`, 'g')) || []).length;
if (opens > closes) break;
result = head;
continue;
}
if (TRAILING_PUNCT.has(last)) {
result = result.slice(0, -1);
continue;
}
break;
}
return result;
};
const extractPreviewUrl = (chunk: string): string | null => {
if (!chunk) return null;
const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, '');
const match = cleaned.match(URL_PATTERN);
if (!match?.[1]) return null;
let url = trimUrlTrailingPunctuation(match[1]);
// Normalize common loopback hostnames to a stable value so the iframe can load.
url = url.replace('0.0.0.0', '127.0.0.1');
url = url.replace('[::1]', '127.0.0.1');
url = url.replace('[::]', '127.0.0.1');
return url;
};
const extractPythonHttpServerUrl = (chunk: string): string | null => {
if (!chunk) return null;
const cleaned = chunk.replace(ANSI_ESCAPE_PATTERN, '');
const match = cleaned.match(/Serving HTTP on .*? port (\d{2,5})/i);
if (!match?.[1]) return null;
const port = Number.parseInt(match[1], 10);
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
return `http://127.0.0.1:${port}/`;
};
const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({
tabs: [firstTab],
activeTabId: firstTab.id,
@@ -527,17 +466,11 @@ export const useTerminalStore = create<TerminalStore>()(
bufferLength -= removed.data.length;
}
const maybePreviewUrl = tab.previewUrlLocked ? null : extractPreviewUrl(chunk) ?? extractPythonHttpServerUrl(chunk);
const shouldUpdatePreview = Boolean(maybePreviewUrl && maybePreviewUrl !== tab.previewUrl);
const nextTabs = [...existing.tabs];
nextTabs[idx] = {
...tab,
bufferChunks,
bufferLength,
...(shouldUpdatePreview
? { previewUrl: maybePreviewUrl, previewAutoOpened: false }
: null),
};
newSessions.set(key, { ...existing, tabs: nextTabs });