Improve WebCRT multi-tab performance and inventory auth UX.

Keep two warm terminals, coalesce stdout per frame, trim scrollback, and allow Telnet credential prompts for managed/quick-connect targets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 18:32:06 +08:00
parent 03ab449277
commit 7b62824c2c
2 changed files with 52 additions and 13 deletions

View file

@ -90,7 +90,7 @@ function serializeTerminal(term: Terminal): string {
return lines.join("\n").replace(/\s+$/g, ""); return lines.join("\n").replace(/\s+$/g, "");
} }
/** Line-wise search — avoids serializing the full 10k scrollback on every Find. */ /** Line-wise search — avoids serializing the full scrollback on every Find. */
function findBufferLine( function findBufferLine(
term: Terminal, term: Terminal,
query: string, query: string,
@ -402,7 +402,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
cursorBlink: true, cursorBlink: true,
fontSize: fontSizeRef.current, fontSize: fontSizeRef.current,
fontFamily: 'Consolas, "Courier New", monospace', fontFamily: 'Consolas, "Courier New", monospace',
scrollback: 10000, // Lower than CRT-style 10k: remounts + multi-tab stay lighter; server replays log tail on attach.
scrollback: 4000,
theme: xtermThemeFromColors(termColorsRef.current), theme: xtermThemeFromColors(termColorsRef.current),
convertEol: true, convertEol: true,
}); });
@ -443,11 +444,32 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const isActiveSocket = () => !cancelled && wsRef.current === ws; const isActiveSocket = () => !cancelled && wsRef.current === ws;
// Coalesce high-rate stdout into one paint frame to cut xterm write churn.
let writeBuf = "";
let writeRaf = 0;
const flushWriteBuf = () => {
writeRaf = 0;
const chunk = writeBuf;
writeBuf = "";
if (!chunk || cancelled) return;
term.write(applyKeywordHighlight(chunk, keywordHighlightRef.current));
};
const writeStdout = (raw: string) => { const writeStdout = (raw: string) => {
if (!raw || !isActiveSocket()) return; if (!raw || !isActiveSocket()) return;
lastStdoutAtRef.current = performance.now(); lastStdoutAtRef.current = performance.now();
if (recordingRef.current) onStdoutRef.current?.(raw); if (recordingRef.current) onStdoutRef.current?.(raw);
term.write(applyKeywordHighlight(raw, keywordHighlightRef.current)); writeBuf += raw;
if (writeBuf.length >= 16384) {
if (writeRaf) {
cancelAnimationFrame(writeRaf);
writeRaf = 0;
}
flushWriteBuf();
return;
}
if (!writeRaf) {
writeRaf = requestAnimationFrame(flushWriteBuf);
}
}; };
const sendResize = () => { const sendResize = () => {
@ -640,6 +662,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
return () => { return () => {
cancelled = true; cancelled = true;
if (writeRaf) {
cancelAnimationFrame(writeRaf);
writeRaf = 0;
}
writeBuf = "";
window.clearInterval(pingTimer); window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize); window.removeEventListener("resize", onWinResize);
window.removeEventListener("keydown", onKeyDownCapture, true); window.removeEventListener("keydown", onKeyDownCapture, true);

View file

@ -1,6 +1,7 @@
import { import {
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type DragEvent as ReactDragEvent, type DragEvent as ReactDragEvent,
@ -77,6 +78,8 @@ const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const;
/** Compact recording chunks before they grow unbounded (join + trim). */ /** Compact recording chunks before they grow unbounded (join + trim). */
const LOG_COMPACT_CHUNKS = 1500; const LOG_COMPACT_CHUNKS = 1500;
const LOG_MAX_CHARS = 8 * 1024 * 1024; const LOG_MAX_CHARS = 8 * 1024 * 1024;
/** Keep active + (N-1) recent tabs mounted; colder tabs detach WS/xterm until focused. */
const WARM_TAB_LIMIT = 2;
type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom"; type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom";
@ -343,11 +346,10 @@ function isSshAuthFailure(err: unknown): boolean {
); );
} }
/** Inventory SSH (managed / webcrt Quick Connect). UME uses shared CLI profile — no per-NE popup. */ /** Inventory managed / Quick Connect. UME uses shared CLI profile — no per-NE popup. */
function isInventorySsh(target: Pick<CliTargetItem, "source" | "protocol">): boolean { function isInventoryTarget(target: Pick<CliTargetItem, "source" | "protocol">): boolean {
const src = String(target.source || "").toLowerCase(); const src = String(target.source || "").toLowerCase();
if (src !== "webcrt" && src !== "managed") return false; return src === "webcrt" || src === "managed";
return String(target.protocol || "ssh").toLowerCase() !== "telnet";
} }
function isSessionGoneError(err: unknown): boolean { function isSessionGoneError(err: unknown): boolean {
@ -667,6 +669,8 @@ export function WebcrtPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [tabs, setTabs] = useState<TermTab[]>([]); const [tabs, setTabs] = useState<TermTab[]>([]);
const [activeTabKey, setActiveTabKey] = useState(""); const [activeTabKey, setActiveTabKey] = useState("");
/** MRU tab keys for warm-mount (active always mounts via isActive even before this updates). */
const [warmOrder, setWarmOrder] = useState<string[]>([]);
const [sftpOpen, setSftpOpen] = useState(false); const [sftpOpen, setSftpOpen] = useState(false);
const [sftpPath, setSftpPath] = useState("."); const [sftpPath, setSftpPath] = useState(".");
const [sftpBusy, setSftpBusy] = useState(false); const [sftpBusy, setSftpBusy] = useState(false);
@ -725,6 +729,13 @@ export function WebcrtPage() {
const sessionOptsRef = useRef(sessionOpts); const sessionOptsRef = useRef(sessionOpts);
sessionOptsRef.current = sessionOpts; sessionOptsRef.current = sessionOpts;
useEffect(() => {
if (!activeTabKey) return;
setWarmOrder((prev) => [activeTabKey, ...prev.filter((k) => k !== activeTabKey)].slice(0, 16));
}, [activeTabKey]);
const warmTabKeys = useMemo(() => new Set(warmOrder.slice(0, WARM_TAB_LIMIT)), [warmOrder]);
useEffect(() => { useEffect(() => {
if (!optionsMenuOpen) return; if (!optionsMenuOpen) return;
const onDoc = (e: MouseEvent) => { const onDoc = (e: MouseEvent) => {
@ -869,8 +880,8 @@ export function WebcrtPage() {
} }
if (connectingKeysRef.current.has(key)) return; if (connectingKeysRef.current.has(key)) return;
// Managed / WebCRT SSH without saved password → credential popup (UME uses shared profile). // Managed / WebCRT without saved password → credential popup (UME uses shared profile).
if (isInventorySsh(target) && !target.has_password && !opts?.force) { if (isInventoryTarget(target) && !target.has_password && !opts?.force) {
openAuthForTarget(target); openAuthForTarget(target);
return; return;
} }
@ -944,7 +955,7 @@ export function WebcrtPage() {
} catch (err) { } catch (err) {
const message = webcrtErrorMessage(err, t); const message = webcrtErrorMessage(err, t);
const needAuth = const needAuth =
isInventorySsh(target) && isInventoryTarget(target) &&
(String(err).includes("credentials_incomplete") || (String(err).includes("credentials_incomplete") ||
String(err).includes("connect_failed") || String(err).includes("connect_failed") ||
isSshAuthFailure(err)); isSshAuthFailure(err));
@ -2188,8 +2199,9 @@ export function WebcrtPage() {
<div className="webcrt-main__terms"> <div className="webcrt-main__terms">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = activeTabKey === tab.key; const isActive = activeTabKey === tab.key;
// Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus. // Cap concurrent xterm+WS: active always mounts; keep one recent background warm.
const mountTerminal = Boolean(isActive && tab.wsUrl); // Colder tabs detach (server grace) and re-attach on focus via the same session_id.
const mountTerminal = Boolean(tab.wsUrl && (isActive || warmTabKeys.has(tab.key)));
return ( return (
<div <div
key={tab.key} key={tab.key}
@ -2339,7 +2351,7 @@ export function WebcrtPage() {
if (sid) { if (sid) {
void closeWebcrtSession(sid).catch(() => undefined); void closeWebcrtSession(sid).catch(() => undefined);
} }
if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) { if (isInventoryTarget(tab.target) && isSshAuthFailure(errMsg)) {
openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t)); openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t));
} }
} else if (state === "closed") { } else if (state === "closed") {