Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions apps/desktop/src/renderer/lib/terminal/terminal-addons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ import { installRectangleRendererAlphaPatch } from "./webgl-vibrancy-patch";
export interface LoadAddonsResult {
searchAddon: SearchAddon;
progressAddon: ProgressAddon;
clearTextureAtlas: () => void;
dispose: () => void;
}

interface LoadAddonsOptions {
onRendererChange?: () => void;
}

// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern).
let suggestedRendererType: "webgl" | "dom" | undefined;

Expand All @@ -22,7 +27,10 @@ let suggestedRendererType: "webgl" | "dom" | undefined;
* function and addon instances. WebGL is deferred to rAF to avoid
* racing with xterm's post-open viewport sync.
*/
export function loadAddons(terminal: XTerm): LoadAddonsResult {
export function loadAddons(
terminal: XTerm,
options: LoadAddonsOptions = {},
): LoadAddonsResult {
let disposed = false;
let webglAddon: WebglAddon | null = null;

Expand Down Expand Up @@ -52,14 +60,16 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult {
webglAddon.onContextLoss(() => {
webglAddon?.dispose();
webglAddon = null;
options.onRendererChange?.();
terminal.refresh(0, terminal.rows - 1);
});
terminal.loadAddon(webglAddon);
// Make explicit-bg cells honor the alpha we put on `theme.ansi[]`
// when vibrancy is enabled — without this codex / Claude Code TUI
// blocks render as opaque black even though the rest of the terminal
// is transparent. See `webgl-vibrancy-patch.ts` for the details.
// FORK NOTE: Make explicit-bg cells honor the alpha we put on
// `theme.ansi[]` when vibrancy is enabled — without this codex /
// Claude Code TUI blocks render as opaque black even though the
// rest of the terminal is transparent. See `webgl-vibrancy-patch.ts`.
installRectangleRendererAlphaPatch(webglAddon);
options.onRendererChange?.();
} catch {
suggestedRendererType = "dom";
webglAddon = null;
Expand All @@ -69,6 +79,11 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult {
return {
searchAddon,
progressAddon,
clearTextureAtlas: () => {
try {
webglAddon?.clearTextureAtlas();
} catch {}
},
dispose: () => {
disposed = true;
cancelAnimationFrame(rafId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ class TerminalRuntimeRegistryImpl {
);
}

getTitle(terminalId: string, instanceId?: string): string | null | undefined {
return this.getEntry(terminalId, instanceId)?.transport.title;
}

onStateChange(
terminalId: string,
listener: () => void,
Expand All @@ -408,6 +412,18 @@ class TerminalRuntimeRegistryImpl {
entry.transport.stateListeners.delete(listener);
};
}

onTitleChange(
terminalId: string,
listener: () => void,
instanceId = terminalId,
): () => void {
const entry = this.getOrCreateEntry(terminalId, instanceId);
entry.transport.titleListeners.add(listener);
return () => {
entry.transport.titleListeners.delete(listener);
};
}
}

// In dev, preserve the singleton across Vite HMR so active WebSocket
Expand Down
103 changes: 101 additions & 2 deletions apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const DIMS_KEY_PREFIX = "terminal-dims:";
const DEFAULT_COLS = 120;
const DEFAULT_ROWS = 32;
const RESIZE_DEBOUNCE_MS = 75;
const FONT_SETTLE_TIMEOUT_MS = 1000;
const FONT_LOAD_SAMPLE_TEXT = "W";

// xterm's _keyDown calls stopPropagation after processing, so any chord we
// want the host (react-hotkeys-hook, Electron menu accelerators) or the shell
Expand Down Expand Up @@ -87,6 +89,10 @@ export interface TerminalRuntime {
container: HTMLDivElement | null;
resizeObserver: ResizeObserver | null;
_disposeResizeObserver: (() => void) | null;
_disposeFontSettle: (() => void) | null;
_onResize: (() => void) | null;
_clearTextureAtlas: (() => void) | null;
_fontSettleToken: number;
lastCols: number;
lastRows: number;
_disposeAddons: (() => void) | null;
Expand Down Expand Up @@ -185,6 +191,53 @@ function hostIsVisible(container: HTMLDivElement | null): boolean {
return container.clientWidth > 0 && container.clientHeight > 0;
}

function waitForNextFrame(): Promise<void> {
if (typeof requestAnimationFrame !== "function") {
return Promise.resolve();
}
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}

function waitForTerminalFont(
terminal: XTerm,
timeoutMs = FONT_SETTLE_TIMEOUT_MS,
): Promise<void> {
const fontFamily = terminal.options.fontFamily;
const fontSize = terminal.options.fontSize;
if (
typeof document === "undefined" ||
!("fonts" in document) ||
typeof fontFamily !== "string" ||
typeof fontSize !== "number"
) {
return waitForNextFrame();
}

const fontSpec = `${fontSize}px ${fontFamily}`;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, timeoutMs);
});
let fontLoad: Promise<void>;
try {
fontLoad = document.fonts
.load(fontSpec, FONT_LOAD_SAMPLE_TEXT)
.catch(() => document.fonts.ready)
.then(() => undefined)
.catch(() => undefined);
} catch {
fontLoad = document.fonts.ready
.then(() => undefined)
.catch(() => undefined);
}

return Promise.race([fontLoad, timeout])
.then(() => waitForNextFrame())
.finally(() => {
if (timeoutId !== null) clearTimeout(timeoutId);
});
}

// Body-level hidden container that owns wrapper divs of terminals whose
// React component is currently unmounted (e.g. workspace switch). Keeps
// xterm attached to the document so it survives provider remounts without
Expand Down Expand Up @@ -240,6 +293,33 @@ function measureAndResize(runtime: TerminalRuntime): boolean {
return terminal.cols !== prevCols || terminal.rows !== prevRows;
}

function scheduleFontSettleRefit(runtime: TerminalRuntime) {
runtime._disposeFontSettle?.();

let disposed = false;
const token = runtime._fontSettleToken + 1;
runtime._fontSettleToken = token;

runtime._disposeFontSettle = () => {
disposed = true;
if (runtime._fontSettleToken === token) {
runtime._disposeFontSettle = null;
}
};

void waitForTerminalFont(runtime.terminal).then(() => {
if (disposed || runtime._fontSettleToken !== token) return;
runtime._disposeFontSettle = null;
if (!hostIsVisible(runtime.container)) return;

// A late-loading font can change cell metrics after xterm's first fit.
runtime._clearTextureAtlas?.();
if (measureAndResize(runtime)) {
runtime._onResize?.();
}
});
}

function createResizeScheduler(
runtime: TerminalRuntime,
onResize?: () => void,
Expand Down Expand Up @@ -303,14 +383,19 @@ export function createRuntime(

// Activate Unicode 11 widths (inside loadAddons) before restoring the buffer,
// else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572)
const addonsResult = loadAddons(terminal);
let runtime: TerminalRuntime | null = null;
const addonsResult = loadAddons(terminal, {
onRendererChange: () => {
if (runtime) scheduleFontSettleRefit(runtime);
},
});
if (options.initialBuffer !== undefined) {
terminal.write(options.initialBuffer);
} else {
restoreBuffer(terminalId, terminal);
}

return {
runtime = {
terminalId,
terminal,
fitAddon,
Expand All @@ -321,10 +406,15 @@ export function createRuntime(
container: null,
resizeObserver: null,
_disposeResizeObserver: null,
_disposeFontSettle: null,
_onResize: null,
_clearTextureAtlas: addonsResult.clearTextureAtlas,
_fontSettleToken: 0,
lastCols: cols,
lastRows: rows,
_disposeAddons: addonsResult.dispose,
};
return runtime;
}

export function attachToContainer(
Expand All @@ -335,6 +425,7 @@ export function attachToContainer(
// If we're already attached to this exact container, do nothing. Prevents
// redundant refresh/focus/fit from transient remounts during provider key
// churn — VSCode setVisible() is idempotent for the same host element.
runtime._onResize = onResize ?? null;
const sameContainer =
runtime.container === container &&
runtime.wrapper.parentElement === container;
Expand All @@ -350,6 +441,7 @@ export function attachToContainer(
containerHeight: container.clientHeight,
});
if (measureAndResize(runtime)) onResize?.();
scheduleFontSettleRefit(runtime);

// Renderer may have skipped frames while the wrapper was detached.
// (refresh is now handled inside measureAndResize)
Expand Down Expand Up @@ -394,6 +486,9 @@ export function detachFromContainer(runtime: TerminalRuntime) {
);
runtime._disposeResizeObserver?.();
runtime._disposeResizeObserver = null;
runtime._disposeFontSettle?.();
runtime._disposeFontSettle = null;
runtime._onResize = null;
runtime.resizeObserver?.disconnect();
runtime.resizeObserver = null;
// Park instead of .remove() so xterm survives the React unmount —
Expand All @@ -418,6 +513,7 @@ export function updateRuntimeAppearance(
terminal.options.fontSize = appearance.fontSize;
if (hostIsVisible(runtime.container)) {
measureAndResize(runtime);
scheduleFontSettleRefit(runtime);
}
}
}
Expand All @@ -435,6 +531,9 @@ export function disposeRuntime(
runtime._disposeAddons = null;
runtime._disposeResizeObserver?.();
runtime._disposeResizeObserver = null;
runtime._disposeFontSettle?.();
runtime._disposeFontSettle = null;
runtime._onResize = null;
runtime.resizeObserver?.disconnect();
runtime.resizeObserver = null;
runtime.wrapper.remove();
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ type TerminalServerMessage =
| { type: "data"; data: string }
| { type: "error"; message: string }
| { type: "exit"; exitCode: number; signal: number }
| { type: "replay"; data: string };
| { type: "replay"; data: string }
| { type: "title"; title: string | null };

export interface TerminalTransport {
debugId: string | null;
socket: WebSocket | null;
connectionState: ConnectionState;
/** The URL the socket is currently connected (or connecting) to. */
currentUrl: string | null;
title: string | null | undefined;
onDataDisposable: { dispose(): void } | null;
stateListeners: Set<() => void>;
titleListeners: Set<() => void>;
/** Internal: auto-reconnect timer. */
_reconnectTimer: ReturnType<typeof setTimeout> | null;
/** Internal: reconnect attempt count for backoff. */
Expand All @@ -41,6 +44,17 @@ function setConnectionState(
}
}

function setTerminalTitle(
transport: TerminalTransport,
title: string | null | undefined,
) {
if (transport.title === title) return;
transport.title = title;
for (const listener of transport.titleListeners) {
listener();
}
}

const MAX_RECONNECT_DELAY = 10_000;
const BASE_RECONNECT_DELAY = 500;
const MAX_RECONNECT_ATTEMPTS = 10;
Expand All @@ -51,8 +65,10 @@ export function createTransport(debugId?: string): TerminalTransport {
socket: null,
connectionState: "disconnected",
currentUrl: null,
title: undefined,
onDataDisposable: null,
stateListeners: new Set(),
titleListeners: new Set(),
_reconnectTimer: null,
_reconnectAttempt: 0,
_terminal: null,
Expand Down Expand Up @@ -187,6 +203,11 @@ export function connect(
return;
}

if (message.type === "title") {
setTerminalTitle(transport, message.title);
return;
}

if (message.type === "error") {
terminalRendererDebug.warn(
"ws-server-error",
Expand Down Expand Up @@ -293,6 +314,7 @@ export function disconnect(transport: TerminalTransport) {
transport.currentUrl = null;
transport._terminal = null;
transport._reconnectAttempt = 0;
setTerminalTitle(transport, undefined);
setConnectionState(transport, "disconnected");
transport.onDataDisposable?.dispose();
transport.onDataDisposable = null;
Expand Down Expand Up @@ -337,7 +359,9 @@ export function disposeTransport(transport: TerminalTransport) {
transport.currentUrl = null;
transport._terminal = null;
transport._reconnectAttempt = 0;
setTerminalTitle(transport, undefined);
transport.onDataDisposable?.dispose();
transport.onDataDisposable = null;
transport.stateListeners.clear();
transport.titleListeners.clear();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type {
SelectAutomation,
SelectAutomationRun,
} from "@superset/db/schema";
import { formatDateTimeInTimezone } from "@superset/shared/rrule";
import { cn } from "@superset/ui/utils";
import { useMutation } from "@tanstack/react-query";
import { format } from "date-fns";
import { useEnabledAgents } from "renderer/hooks/useEnabledAgents";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker";
Expand Down Expand Up @@ -81,13 +81,20 @@ export function AutomationDetailSidebar({
label="Next run"
value={
automation.enabled && automation.nextRunAt
? format(new Date(automation.nextRunAt), "MMM d, h:mm a")
? formatDateTimeInTimezone(
new Date(automation.nextRunAt),
automation.timezone,
)
: "—"
}
/>
<Row
label="Last ran"
value={lastRunAt ? format(lastRunAt, "MMM d, h:mm a") : "—"}
value={
lastRunAt
? formatDateTimeInTimezone(lastRunAt, automation.timezone)
: "—"
}
/>
</Section>

Expand Down
Loading
Loading