Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-07-17 13:06:31 +02:00
30 changed files with 1853 additions and 862 deletions
@@ -54,22 +54,20 @@ function registerBrowserWhenAttached(
identity: BrowserWebviewIdentity,
browser: BrowserWebviewProfileHost,
): void {
webview.addEventListener(
"did-attach",
() => {
const webContentsId = webview.getWebContentsId();
void browser
.registerAttachedBrowser({
browserId: identity.browserId,
workspaceId: identity.workspaceId,
webContentsId,
})
.catch((error) => {
console.error("[browser-webview] attached registration failed", error);
});
},
{ once: true },
);
// Reparenting a webview can replace its guest WebContents without replacing
// this DOM element, so every attachment needs a fresh main-process registration.
webview.addEventListener("did-attach", () => {
const webContentsId = webview.getWebContentsId();
void browser
.registerAttachedBrowser({
browserId: identity.browserId,
workspaceId: identity.workspaceId,
webContentsId,
})
.catch((error) => {
console.error("[browser-webview] attached registration failed", error);
});
});
}
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
reconcileFocusClaim,
settleFocusClaim,
type FocusClaimState,
} from "./terminal-pane-focus-claim";
function request(
state: FocusClaimState,
input: { key: string | null; canRequest: boolean },
): FocusClaimState {
return reconcileFocusClaim(state, input).state;
}
describe("terminal pane focus claim", () => {
it("waits for both the client and renderer before requesting a claim", () => {
const withoutClient = canRequestFocusClaim({
isWorkspaceFocused: true,
isAppVisible: true,
isClientReady: false,
isConnected: true,
isRendererReady: true,
});
const withoutRenderer = canRequestFocusClaim({
isWorkspaceFocused: true,
isAppVisible: true,
isClientReady: true,
isConnected: true,
isRendererReady: false,
});
expect([withoutClient, withoutRenderer]).toEqual([false, false]);
});
it("does not deliver a requested claim after the host disconnects", () => {
const disconnected = canRequestFocusClaim({
isWorkspaceFocused: true,
isAppVisible: true,
isClientReady: true,
isConnected: false,
isRendererReady: true,
});
expect(disconnected).toBe(false);
});
it("claims once per continuous pane-focus period after send", () => {
const firstRequest = reconcileFocusClaim(EMPTY_FOCUS_CLAIM_STATE, {
key: "ws:term-1",
canRequest: true,
});
const sent = settleFocusClaim(firstRequest.state, {
key: "ws:term-1",
sent: true,
});
const repeated = reconcileFocusClaim(sent, {
key: "ws:term-1",
canRequest: true,
});
expect(firstRequest.shouldRequest).toBe(true);
expect(repeated).toEqual({
state: { claimedKey: "ws:term-1", requestedKey: null },
shouldRequest: false,
});
});
it("defers until the claim can be requested", () => {
const unavailable = reconcileFocusClaim(EMPTY_FOCUS_CLAIM_STATE, {
key: "ws:term-1",
canRequest: false,
});
const available = reconcileFocusClaim(unavailable.state, {
key: "ws:term-1",
canRequest: true,
});
expect(unavailable.shouldRequest).toBe(false);
expect(available.shouldRequest).toBe(true);
});
it("retries when readiness changes before the requested claim lands", () => {
const requested = request(EMPTY_FOCUS_CLAIM_STATE, {
key: "ws:term-1",
canRequest: true,
});
const hiddenBeforeDelivery = request(requested, {
key: "ws:term-1",
canRequest: false,
});
const visibleAgain = reconcileFocusClaim(hiddenBeforeDelivery, {
key: "ws:term-1",
canRequest: true,
});
expect(visibleAgain).toEqual({
state: { claimedKey: null, requestedKey: "ws:term-1" },
shouldRequest: true,
});
});
it("retries a requested claim that was not sent", () => {
const requested = request(EMPTY_FOCUS_CLAIM_STATE, {
key: "ws:term-1",
canRequest: true,
});
const dropped = settleFocusClaim(requested, {
key: "ws:term-1",
sent: false,
});
const retry = reconcileFocusClaim(dropped, {
key: "ws:term-1",
canRequest: true,
});
expect(retry.shouldRequest).toBe(true);
});
it("re-arms after pane blur or terminal change", () => {
const requested = request(EMPTY_FOCUS_CLAIM_STATE, {
key: "ws:term-1",
canRequest: true,
});
const sent = settleFocusClaim(requested, { key: "ws:term-1", sent: true });
const blurred = request(sent, { key: null, canRequest: true });
const refocused = reconcileFocusClaim(blurred, { key: "ws:term-1", canRequest: true });
const changed = reconcileFocusClaim(sent, { key: "ws:term-2", canRequest: true });
expect(refocused.shouldRequest).toBe(true);
expect(changed.shouldRequest).toBe(true);
});
});
@@ -0,0 +1,73 @@
export interface FocusClaimState {
claimedKey: string | null;
requestedKey: string | null;
}
export interface FocusClaimStep {
state: FocusClaimState;
shouldRequest: boolean;
}
interface FocusClaimReadiness {
isWorkspaceFocused: boolean;
isAppVisible: boolean;
isClientReady: boolean;
isConnected: boolean;
isRendererReady: boolean;
}
export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = {
claimedKey: null,
requestedKey: null,
};
export function canRequestFocusClaim(input: FocusClaimReadiness): boolean {
return (
input.isWorkspaceFocused &&
input.isAppVisible &&
input.isClientReady &&
input.isConnected &&
input.isRendererReady
);
}
export function reconcileFocusClaim(
state: FocusClaimState,
input: { key: string | null; canRequest: boolean },
): FocusClaimStep {
if (input.key === null) {
return { state: EMPTY_FOCUS_CLAIM_STATE, shouldRequest: false };
}
if (state.claimedKey === input.key) {
return {
state: { claimedKey: input.key, requestedKey: null },
shouldRequest: false,
};
}
if (!input.canRequest) {
return {
state: { claimedKey: state.claimedKey, requestedKey: null },
shouldRequest: false,
};
}
if (state.requestedKey === input.key) {
return { state, shouldRequest: false };
}
return {
state: { claimedKey: state.claimedKey, requestedKey: input.key },
shouldRequest: true,
};
}
export function settleFocusClaim(
state: FocusClaimState,
input: { key: string; sent: boolean },
): FocusClaimState {
if (state.requestedKey !== input.key) {
return state;
}
return {
claimedKey: input.sent ? input.key : state.claimedKey,
requestedKey: null,
};
}
@@ -1,92 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveFocusLatchStep, type FocusLatchStep } from "./terminal-pane-focus-latch";
/**
* Pins the ordering that caused "terminal stuck at 80x24": the latch must record that the action
* RAN, not that the effect ran. A pane that mounts before its workspace has focus (or while the
* app is hidden) must still fire once readiness arrives — burning the latch on the attempt
* permanently disarms the only resize claim a fresh terminal ever sends.
*/
function run(steps: Array<{ key: string | null; canFire: boolean }>): FocusLatchStep[] {
const results: FocusLatchStep[] = [];
let latchedKey: string | null = null;
for (const step of steps) {
const result = resolveFocusLatchStep({ ...step, latchedKey });
latchedKey = result.latchedKey;
results.push(result);
}
return results;
}
describe("resolveFocusLatchStep", () => {
it("fires immediately when the pane is focused and ready at mount", () => {
const [step] = run([{ key: "ws:term-1", canFire: true }]);
expect(step).toEqual({ latchedKey: "ws:term-1", fire: true });
});
it("defers instead of burning the latch when readiness arrives late", () => {
// The bug: pane focused while the workspace is not (or the app is hidden). The old code
// latched on the first pass and never fired again.
const steps = run([
{ key: "ws:term-1", canFire: false }, // mount: pane focused, workspace not focused yet
{ key: "ws:term-1", canFire: true }, // workspace focus / visibility arrives
]);
expect(steps[0]).toEqual({ latchedKey: null, fire: false });
expect(steps[1]).toEqual({ latchedKey: "ws:term-1", fire: true });
});
it("fires exactly once per continuous pane-focus period", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-1", canFire: true }, // unrelated re-render
{ key: "ws:term-1", canFire: false }, // workspace blurs...
{ key: "ws:term-1", canFire: true }, // ...and refocuses: not a new pane-focus period
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-1", fire: false },
{ latchedKey: "ws:term-1", fire: false },
{ latchedKey: "ws:term-1", fire: false },
]);
});
it("re-arms when the pane blurs and fires again on refocus", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: null, canFire: true }, // pane blurred / terminal gone
{ key: "ws:term-1", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: null, fire: false },
{ latchedKey: "ws:term-1", fire: true },
]);
});
it("fires for a different terminal in the same pane", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-2", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-2", fire: true },
]);
});
it("stays deferred across repeated not-ready passes, then fires once", () => {
const steps = run([
{ key: "ws:term-1", canFire: false },
{ key: "ws:term-1", canFire: false },
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-1", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: null, fire: false },
{ latchedKey: null, fire: false },
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-1", fire: false },
]);
});
});
@@ -1,33 +0,0 @@
/**
* Once-per-pane-focus latch for terminal-pane's focus/reflow effects.
*
* Each effect wants to fire exactly once per continuous pane-focus period per terminal, but only
* while the action can actually land (workspace focused, and for resize claims the app visible —
* `handleTerminalResize` drops claims emitted while hidden and nothing re-sends them). The latch
* must therefore record "the action ran", never "we tried": latching before the readiness gate
* permanently disarms the effect when a pane mounts before its workspace has focus, which is how
* a terminal ends up rendering full-size while its PTY stays at the daemon's 80x24 default.
*/
export interface FocusLatchStep {
latchedKey: string | null;
fire: boolean;
}
export function resolveFocusLatchStep(input: {
/** Identity of the pane-focus period; null resets the latch (pane blurred, no terminal). */
key: string | null;
latchedKey: string | null;
/** Whether the action can land right now; while false the latch stays untouched. */
canFire: boolean;
}): FocusLatchStep {
if (input.key === null) {
return { latchedKey: null, fire: false };
}
if (input.latchedKey === input.key) {
return { latchedKey: input.latchedKey, fire: false };
}
if (!input.canFire) {
return { latchedKey: input.latchedKey, fire: false };
}
return { latchedKey: input.key, fire: true };
}
+64 -34
View File
@@ -21,7 +21,12 @@ import {
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import { resolveFocusLatchStep } from "./terminal-pane-focus-latch";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
reconcileFocusClaim,
settleFocusClaim,
} from "./terminal-pane-focus-claim";
import {
TerminalStreamController,
type TerminalStreamControllerStatus,
@@ -222,7 +227,7 @@ export function TerminalPane({
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const lastPaneFocusResizeKeyRef = useRef<string | null>(null);
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => {
@@ -261,40 +266,48 @@ export function TerminalPane({
);
useEffect(() => {
// Latch only when the focus actually ran: latching before the workspace-focus gate would
// permanently disarm this effect for panes that mount before their workspace has focus.
const step = resolveFocusLatchStep({
key: isMobile || !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`,
latchedKey: lastAutoFocusKeyRef.current,
canFire: isWorkspaceFocused,
});
lastAutoFocusKeyRef.current = step.latchedKey;
if (step.fire) {
if (isMobile || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
if (!isWorkspaceFocused) {
return;
}
const focusKey = `${scopeKey}:${terminalId}`;
if (lastAutoFocusKeyRef.current !== focusKey) {
lastAutoFocusKeyRef.current = focusKey;
requestTerminalFocus();
}
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => {
// This reflow produces the one resize claim a freshly mounted terminal ever sends; defer it
// (without burning the latch) until the claim can land — handleTerminalResize drops claims
// while the workspace is unfocused or the app is hidden, and nothing re-sends a dropped one.
const step = resolveFocusLatchStep({
key: !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`,
latchedKey: lastPaneFocusResizeKeyRef.current,
canFire: isWorkspaceFocused && isAppVisible,
const canRequest = canRequestFocusClaim({
isWorkspaceFocused,
isAppVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
});
lastPaneFocusResizeKeyRef.current = step.latchedKey;
if (step.fire) {
const step = reconcileFocusClaim(paneFocusResizeClaimRef.current, {
key: !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`,
canRequest,
});
paneFocusResizeClaimRef.current = step.state;
if (step.shouldRequest) {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}
}, [
client,
isAppVisible,
isConnected,
isPaneFocused,
isWorkspaceFocused,
rendererReadyStreamKey,
requestTerminalReflow,
scopeKey,
terminalId,
terminalStreamKey,
]);
const handleTerminalFocus = useCallback(() => {
@@ -635,23 +648,40 @@ export function TerminalPane({
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
if (!input.shouldClaim) {
return;
}
const previousSent = lastSentTerminalSizeRef.current;
if (
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
) {
return;
}
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
let sent = false;
const canSend = canRequestFocusClaim({
isWorkspaceFocused,
isAppVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: true,
});
if (client && terminalId && canSend) {
const previousSent = lastSentTerminalSizeRef.current;
if (
!previousSent ||
previousSent.rows !== normalizedRows ||
previousSent.cols !== normalizedCols
) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
}
sent = true;
}
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
if (requestedKey) {
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
key: requestedKey,
sent,
});
}
},
);
@@ -66,6 +66,10 @@ async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }):
}
}
function settleMountRefits(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 2_600));
}
function createTerminalHost(input: {
width: number;
height: number;
@@ -204,8 +208,17 @@ describe("terminal emulator runtime in a real browser", () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
await settleMountRefits();
expect(mounted.sizes[0]?.shouldClaim).toBe(false);
expect(mounted.sizes.length).toBeGreaterThan(1);
expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([]);
const settledSize = latestSize(mounted.sizes);
mounted.runtime.resize({ force: true, shouldClaim: true });
expect(mounted.sizes.filter((size) => size.shouldClaim)).toEqual([
{ ...settledSize, shouldClaim: true },
]);
});
it("reports a larger PTY size when the terminal container grows", async () => {
@@ -1,159 +0,0 @@
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it } from "vitest";
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
/**
* Pins the mechanism behind "terminal stuck at 80x24".
*
* The daemon spawns a PTY at 80x24 and only resizes it when the client sends a resize frame.
* `handleTerminalResize` in terminal-pane only forwards a frame when the emitted fit carries
* `shouldClaim: true`.
*
* These tests show that a freshly mounted terminal NEVER emits such a fit. Every mount-time
* refit (the mount fit, the retry ladder, fonts.ready, the WebGL swap, visibility restore) is
* emitted with `shouldClaim: false`. The ResizeObserver's first delivery IS `shouldClaim: true`,
* but it is emitted without `force`, so the runtime's own dedupe drops it: the size has not
* changed since the mount fit.
*
* So after a fresh mount, in a pane whose size never changes, the client sends the daemon
* NOTHING. The PTY keeps whatever size it was created with. The terminal's size then depends
* entirely on either the size carried at subscribe, or an explicit forced reflow
* (`requestTerminalReflow` -> `runtime.resize({force, shouldClaim})`) -- which is exactly the
* path terminal-pane's focus effects can permanently latch off.
*/
interface EmittedSize {
rows: number;
cols: number;
shouldClaim: boolean;
}
interface MountedTerminal {
root: HTMLDivElement;
runtime: TerminalEmulatorRuntime;
sizes: EmittedSize[];
}
const mounted: MountedTerminal[] = [];
afterEach(() => {
for (const entry of mounted.splice(0)) {
entry.runtime.unmount();
entry.root.remove();
}
});
function mountTerminal(input: { width: number; height: number }): MountedTerminal {
const root = document.createElement("div");
root.style.cssText = `width:${input.width}px;height:${input.height}px;position:fixed;left:0;top:0;overflow:hidden`;
const host = document.createElement("div");
host.style.cssText = "width:100%;height:100%";
root.appendChild(host);
document.body.appendChild(root);
const sizes: EmittedSize[] = [];
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({
callbacks: {
onResize: (size) => {
sizes.push(size as EmittedSize);
},
},
});
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 1_000,
theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" },
});
const entry = { root, runtime, sizes };
mounted.push(entry);
return entry;
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
resolve();
});
});
}
async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): Promise<void> {
const startedAt = performance.now();
const timeoutMs = input.timeoutMs ?? 2_000;
while (!input.predicate()) {
if (performance.now() - startedAt > timeoutMs) {
throw new Error("Timed out waiting for terminal browser condition");
}
await nextFrame();
}
}
/**
* Outlast the runtime's whole refit ladder (0/16/48/120/250/500/1000/2000ms) plus fonts and the
* WebGL swap, so nothing is still in flight when we assert. Unlike every other wait in this file
* this one cannot be a predicate: the point is to prove that no claim EVER arrives, and there is
* no event that says "the mount is done emitting".
*/
function settleMountRefits(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 2_600));
}
describe("a freshly mounted terminal never claims its size", () => {
it("emits many fits on mount, and not one of them claims the PTY size", async () => {
await page.viewport(1400, 900);
const terminal = mountTerminal({ width: 900, height: 600 });
await settleMountRefits();
// It really did refit repeatedly while mounting...
expect(terminal.sizes.length).toBeGreaterThan(1);
// ...and it measured a real viewport, not the daemon's default.
expect(terminal.sizes.at(-1)?.cols).not.toBe(80);
// ...but every single one of those fits declined to own the PTY size, so terminal-pane
// forwards NOTHING to the daemon. This is why a terminal can sit at 80x24 while its xterm
// renders perfectly at the real size.
const claims = terminal.sizes.filter((size) => size.shouldClaim);
expect(claims).toEqual([]);
});
it("only an explicit forced reflow claims — the path the focus latch can kill", async () => {
await page.viewport(1400, 900);
const terminal = mountTerminal({ width: 900, height: 600 });
await settleMountRefits();
expect(terminal.sizes.filter((size) => size.shouldClaim)).toEqual([]);
const settledSize = terminal.sizes.at(-1);
// This is what requestTerminalReflow does. It is the ONLY thing that makes a
// stable-sized, freshly mounted terminal tell the daemon how big it is.
terminal.runtime.resize({ force: true, shouldClaim: true });
// It claims the size the terminal actually settled at, so the PTY ends up matching what the
// user sees rendered.
expect(terminal.sizes.filter((size) => size.shouldClaim)).toEqual([
{ rows: settledSize?.rows, cols: settledSize?.cols, shouldClaim: true },
]);
});
it("a genuine size change does claim — which is why resizing the window unsticks it", async () => {
await page.viewport(1400, 900);
const terminal = mountTerminal({ width: 900, height: 600 });
await settleMountRefits();
expect(terminal.sizes.filter((size) => size.shouldClaim)).toEqual([]);
// The ResizeObserver fit IS shouldClaim:true; it is only suppressed while the size is
// unchanged. Change the box and it gets through -- the user-visible "resize the window and
// it fixes itself" behaviour.
terminal.root.style.width = "1300px";
await waitFor({ predicate: () => terminal.sizes.some((size) => size.shouldClaim) });
const claims = terminal.sizes.filter((size) => size.shouldClaim);
// The claim carries the NEW width, not the size it mounted at.
expect(claims.at(-1)?.cols).toBeGreaterThan(terminal.sizes[0]?.cols ?? 0);
});
});