Keep terminal sizing reliable through focus changes (#2154)
* fix(terminal): retry size claims until sent A focus resize could consume its once-per-focus latch before the renderer handed the size to the client. Keep the claim pending across visibility, connection, and renderer readiness changes, and only commit it after the resize is sent. * fix(terminal): keep disconnected claims retryable A retained runtime client can outlive its active connection. Require the connection at delivery time so a delayed resize callback cannot commit a claim that was never sent. * test(terminal): await PTY resize observation Prove the terminal starts at 80x24 while blurred, then probe through the daemon until its own reported size matches xterm after focus returns. This avoids racing a one-shot stty sample against the asynchronous resize claim.
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user