Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andHannah 533f504dfd fix(app): restore retained terminal focus on current main
Keep the workspace autofocus request pending until its renderer is ready,
while preserving overlay and explicitly selected control focus. Reconcile
with current overlay handling and keep the changelog in release format.
Give the button-focus regression a fixed browser clock origin so its pause
cannot precede the installed time.

Co-authored-by: Hannah <hannah@interagent.io>
2026-10-16 14:52:47 +02:00
Hannah ebc4ebc007 fix(app): retain deferred terminal focus within overlays
Keep autofocus pending when focus moves inside the top registered overlay scope, while preserving cancellation for outside controls and workspace or pane loss.

Add a real-browser regression that fails before the fix and passes with terminal typing after overlay dismissal.
2026-10-06 15:58:37 +08:00
Hannah 72d14325e6 fix(app): preserve button focus during deferred terminal autofocus 2026-10-06 15:05:31 +08:00
Hannah 5ead2a8811 fix(app): retry deferred terminal focus after controls release 2026-10-06 14:53:22 +08:00
Hannah 3a10500fcf fix(app): refocus retained terminals after workspace switches 2026-10-06 14:38:47 +08:00
5 changed files with 241 additions and 7 deletions
+5
View File
@@ -95,6 +95,11 @@ highest painted scope alone receives overlay keys, traps focus, and restores
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
stacked overlays would both close on one keypress.
When deferring workspace terminal autofocus, distinguish movement within the
top registered overlay scope from a new focus owner outside it. Internal focus
movement must keep the request pending until the overlay closes; an outside
control cancels it so its next keystroke is not sent to the terminal.
If an overlay is rendered by a global host rather than beneath its opener in
the React tree, carry the opener's current layer through the host store and
restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
@@ -1,6 +1,7 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
interface AttachOverlayProbeWindow extends Window {
__terminalAttachOverlaySeen?: boolean;
@@ -59,4 +60,87 @@ test.describe("retained terminal tab streams", () => {
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
});
test("workspace number shortcuts refocus a retained terminal", async ({ page }) => {
const first = await harness.createTerminal({ name: "focus-first" });
const other = await harness.createOtherWorkspace();
const second = await other.createTerminal({ name: "focus-second" });
await harness.openTerminal(page, { terminalId: first.id });
await other.openTerminal(page, { terminalId: second.id });
await other.expectTerminalFocused(page);
await harness.switchToWorkspaceByShortcut(page);
await harness.expectTerminalFocused(page);
await other.switchToWorkspaceByShortcut(page);
await other.expectTerminalFocused(page);
await other.typeCommandAndExpectOutput(page, {
terminalId: second.id,
command: "printf 'WORKSPACE_FOCUS_OK\\n'",
output: "WORKSPACE_FOCUS_OK",
});
});
test("closing an overlay after returning to a workspace refocuses its terminal", async ({
page,
}) => {
const first = await harness.createTerminal({ name: "overlay-first" });
const other = await harness.createOtherWorkspace();
const second = await other.createTerminal({ name: "overlay-second" });
await harness.openTerminal(page, { terminalId: first.id });
await other.openTerminal(page, { terminalId: second.id });
await harness.switchToWorkspaceByShortcut(page);
await harness.expectTerminalFocused(page);
await harness.rememberWorkspaceForHistoryReturn(page);
await other.switchToWorkspaceByShortcut(page);
const overlay = await openCommandCenter(page);
await harness.returnToWorkspaceThroughHistory(page);
await expect(overlay.getByTestId("command-center-input")).toBeFocused();
await closeCommandCenter(page);
await harness.expectTerminalFocused(page);
await harness.typeCommandAndExpectOutput(page, {
terminalId: first.id,
command: "printf 'OVERLAY_FOCUS_OK\\n'",
output: "OVERLAY_FOCUS_OK",
});
});
test("moving focus inside an overlay preserves deferred terminal focus", async ({ page }) => {
const first = await harness.createTerminal({ name: "overlay-move-first" });
const other = await harness.createOtherWorkspace();
const second = await other.createTerminal({ name: "overlay-move-second" });
await harness.openTerminal(page, { terminalId: first.id });
await other.openTerminal(page, { terminalId: second.id });
await harness.switchToWorkspaceByShortcut(page);
await harness.expectTerminalFocused(page);
await harness.rememberWorkspaceForHistoryReturn(page);
await other.switchToWorkspaceByShortcut(page);
await openCommandCenter(page);
await harness.returnToWorkspaceThroughHistory(page);
await harness.moveFocusWithinCommandCenter(page);
await closeCommandCenter(page);
await harness.expectTerminalFocused(page);
await harness.typeCommandAndExpectOutput(page, {
terminalId: first.id,
command: "printf 'OVERLAY_MOVE_FOCUS_OK\\n'",
output: "OVERLAY_MOVE_FOCUS_OK",
});
});
test("deferred terminal focus does not steal focus from a button", async ({ page }) => {
const first = await harness.createTerminal({ name: "button-first" });
const other = await harness.createOtherWorkspace();
const second = await other.createTerminal({ name: "button-second" });
await harness.openTerminal(page, { terminalId: first.id });
await other.openTerminal(page, { terminalId: second.id });
await harness.switchToWorkspaceByShortcut(page);
await harness.expectTerminalFocused(page);
await harness.rememberWorkspaceForHistoryReturn(page);
await other.switchToWorkspaceByShortcut(page);
const overlay = await openCommandCenter(page);
await harness.returnToWorkspaceThroughHistory(page);
await expect(overlay.getByTestId("command-center-input")).toBeFocused();
await harness.closeOverlayAndFocusButtonBeforeRetry(page);
await expect(page.getByTestId("sidebar-search")).toBeFocused();
await page.keyboard.press("Enter");
await expect(overlay).toBeVisible();
});
});
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import { createTempGitRepo } from "./workspace";
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
@@ -30,6 +30,7 @@ export class TerminalE2EHarness {
readonly tempRepo: TempRepo;
readonly projectId: string;
readonly workspaceId: string;
private readonly otherWorkspaces: TerminalE2EHarness[] = [];
private constructor(input: {
client: SeedDaemonClient;
@@ -63,6 +64,7 @@ export class TerminalE2EHarness {
}
async cleanup(): Promise<void> {
for (const workspace of this.otherWorkspaces) await workspace.cleanup();
await this.client.removeProject(this.projectId).catch(() => {});
await this.client.close().catch(() => {});
await this.tempRepo.cleanup().catch(() => {});
@@ -131,6 +133,78 @@ export class TerminalE2EHarness {
});
}
async createOtherWorkspace(): Promise<TerminalE2EHarness> {
const workspace = await TerminalE2EHarness.create({ tempPrefix: "terminal-workspace-focus-" });
this.otherWorkspaces.push(workspace);
return workspace;
}
async switchToWorkspaceByShortcut(page: Page): Promise<void> {
const rows = page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ visible: true });
const rowIds = await rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
const index = rowIds.findIndex((id) => id?.endsWith(`:${this.workspaceId}`)) + 1;
expect(index).toBeGreaterThan(0);
expect(index).toBeLessThanOrEqual(9);
// Browser Alt+Digit routes the same workspace action as desktop Cmd+Digit.
await page.keyboard.press(`Alt+${index}`);
await expect(page).toHaveURL(new RegExp(`/workspace/${this.workspaceId}`));
}
async rememberWorkspaceForHistoryReturn(page: Page): Promise<void> {
// Workspace shortcuts replace the current route; preserve an in-app history entry.
await page.evaluate(() =>
window.history.pushState(window.history.state, "", window.location.href),
);
}
async returnToWorkspaceThroughHistory(page: Page): Promise<void> {
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/workspace/${this.workspaceId}`));
}
async moveFocusWithinCommandCenter(page: Page): Promise<void> {
const panel = page.getByTestId("command-center-panel");
await expect(panel.getByTestId("command-center-input")).toBeFocused();
await page.keyboard.press("Tab");
await expect(panel.getByRole("button").first()).toBeFocused();
}
async closeOverlayAndFocusButtonBeforeRetry(page: Page): Promise<void> {
// Give the browser clock a fixed origin before pausing it in the future.
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
await page.clock.pauseAt(new Date("2026-01-01T00:01:00Z"));
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await page.getByTestId("sidebar-search").focus();
await page.clock.runFor(100);
await page.clock.resume();
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
async expectTerminalFocused(page: Page): Promise<void> {
await expect(
this.terminalSurface(page).filter({ visible: true }).locator(".xterm-helper-textarea"),
).toBeFocused();
}
async typeCommandAndExpectOutput(
page: Page,
input: { terminalId: string; command: string; output: string },
): Promise<void> {
await page.keyboard.type(input.command);
await page.keyboard.press("Enter");
await expect
.poll(async () => (await this.client.captureTerminal(input.terminalId)).lines)
.toContain(input.output);
}
terminalSurface(page: Page) {
return page.locator('[data-testid="terminal-surface"]');
}
+59 -6
View File
@@ -2,6 +2,11 @@ import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import {
hasActiveWebOverlay,
isWithinActiveWebOverlay,
subscribeWebOverlayChanges,
} from "@/lib/overlay-root";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as Clipboard from "expo-clipboard";
import {
@@ -68,7 +73,7 @@ import {
type TerminalResizeRequest,
} from "./terminal-resize-debouncer";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
@@ -377,19 +382,67 @@ export function TerminalPane({
);
useEffect(() => {
if (isMobile || !isPaneFocused || !terminalId) {
if (isMobile || !isPaneFocused || !isWorkspaceFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
if (!isWorkspaceFocused) {
if (rendererReadyStreamKey !== terminalStreamKey) {
return;
}
const focusKey = `${scopeKey}:${terminalId}`;
if (lastAutoFocusKeyRef.current !== focusKey) {
lastAutoFocusKeyRef.current = focusKey;
if (lastAutoFocusKeyRef.current === focusKey) return;
function tryAutoFocus(): boolean {
if (isWeb) {
const active = document.activeElement;
const hasFocusedInput =
active instanceof HTMLElement &&
active.matches('input, textarea, [contenteditable="true"]') &&
!active.matches(".xterm-helper-textarea") &&
active.getClientRects().length > 0;
if (hasActiveWebOverlay() || hasFocusedInput) {
return false;
}
}
requestTerminalFocus();
lastAutoFocusKeyRef.current = focusKey;
return true;
}
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
if (tryAutoFocus() || !isWeb) return;
let retryFrame: number | null = null;
function stopRetrying(): void {
document.removeEventListener("focusout", scheduleRetry);
document.removeEventListener("focusin", handleFocusIn);
unsubscribeOverlayChanges();
if (retryFrame !== null) window.cancelAnimationFrame(retryFrame);
}
function handleFocusIn(event: FocusEvent): void {
// Moving within the overlay does not replace the deferred workspace focus owner.
if (!isWithinActiveWebOverlay(event.target)) stopRetrying();
}
function scheduleRetry(): void {
if (retryFrame !== null) return;
// Let blur, overlay teardown, and focus restoration settle before checking ownership.
retryFrame = window.requestAnimationFrame(() => {
retryFrame = null;
if (tryAutoFocus()) stopRetrying();
});
}
const unsubscribeOverlayChanges = subscribeWebOverlayChanges(scheduleRetry);
document.addEventListener("focusout", scheduleRetry);
// A new focus owner outside the overlay wins over deferred workspace autofocus.
document.addEventListener("focusin", handleFocusIn);
return stopRetrying;
}, [
isMobile,
isPaneFocused,
isWorkspaceFocused,
rendererReadyStreamKey,
requestTerminalFocus,
scopeKey,
terminalId,
terminalStreamKey,
]);
useEffect(() => {
const canRequest = canRequestFocusClaim({
+18
View File
@@ -95,6 +95,7 @@ const webOverlayEntries: WebOverlayEntry[] = [];
let webOverlayOrder = 0;
let webOverlayListenersAttached = false;
let webOverlayFocusCheckQueued = false;
const webOverlayChangeListeners = new Set<() => void>();
interface RemoveWebOverlayOptions {
restoreFocus?: boolean;
@@ -112,6 +113,21 @@ export function hasActiveWebOverlay(): boolean {
return getTopWebOverlay() !== undefined;
}
export function isWithinActiveWebOverlay(target: EventTarget | null): boolean {
return target instanceof Node && (getTopWebOverlay()?.getScope()?.contains(target) ?? false);
}
export function subscribeWebOverlayChanges(listener: () => void): () => void {
webOverlayChangeListeners.add(listener);
return () => {
webOverlayChangeListeners.delete(listener);
};
}
function notifyWebOverlayChanges(): void {
for (const listener of webOverlayChangeListeners) listener();
}
/**
* True while an element shown with the Popover API is open. Such a layer
* floats above the app like a registered overlay but lives outside the
@@ -239,6 +255,7 @@ function detachWebOverlayListeners(): void {
function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptions) => void {
webOverlayEntries.push(entry);
attachWebOverlayListeners();
notifyWebOverlayChanges();
const focusFrame = window.requestAnimationFrame(() => {
const scope = entry.getScope();
@@ -265,6 +282,7 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
) {
entry.restoreFocus.focus();
}
notifyWebOverlayChanges();
};
}