fix(app): retry deferred terminal focus after controls release

This commit is contained in:
Hannah
2026-10-06 14:53:22 +08:00
parent 3a10500fcf
commit 5ead2a8811
5 changed files with 130 additions and 43 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Fixed
- Fixed retained terminals not regaining keyboard focus after switching back to their workspace with a number shortcut.
- Fixed retained terminals not regaining keyboard focus after switching back to their workspace, including when an overlay or another input delays focus until it is released.
## 0.11.0-beta.5 - 2026-10-06
@@ -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;
@@ -62,42 +63,43 @@ test.describe("retained terminal tab streams", () => {
test("workspace number shortcuts refocus a retained terminal", async ({ page }) => {
const first = await harness.createTerminal({ name: "focus-first" });
const other = await TerminalE2EHarness.create({ tempPrefix: "terminal-workspace-focus-" });
try {
const second = await other.createTerminal({ name: "focus-second" });
await harness.openTerminal(page, { terminalId: first.id });
await other.openTerminal(page, { terminalId: second.id });
const rows = page
.locator('[data-testid^="sidebar-workspace-row-"]')
.filter({ visible: true });
const rowIds = await rows.evaluateAll((elements) => {
const ids: Array<string | null> = [];
for (const element of elements) ids.push(element.getAttribute("data-testid"));
return ids;
});
const firstIndex = rowIds.findIndex((id) => id?.endsWith(`:${harness.workspaceId}`)) + 1;
const secondIndex = rowIds.findIndex((id) => id?.endsWith(`:${other.workspaceId}`)) + 1;
expect(firstIndex).toBeGreaterThan(0);
expect(secondIndex).toBeGreaterThan(0);
const terminalInput = page
.getByTestId("terminal-surface")
.filter({ visible: true })
.locator("textarea");
await expect(terminalInput).toBeFocused();
// Browser Alt+Digit routes the same workspace action as desktop Cmd+Digit.
await page.keyboard.press(`Alt+${firstIndex}`);
await expect(page).toHaveURL(new RegExp(`/workspace/${harness.workspaceId}`));
await expect(terminalInput).toBeFocused();
await page.keyboard.press(`Alt+${secondIndex}`);
await expect(page).toHaveURL(new RegExp(`/workspace/${other.workspaceId}`));
await expect(terminalInput).toBeFocused();
await page.keyboard.type("printf 'WORKSPACE_FOCUS_OK\\n'");
await page.keyboard.press("Enter");
await expect
.poll(async () => (await other.client.captureTerminal(second.id)).lines)
.toContain("WORKSPACE_FOCUS_OK");
} finally {
await other.cleanup();
}
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",
});
});
});
@@ -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,54 @@ 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 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"]');
}
+25 -4
View File
@@ -2,7 +2,7 @@ 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 } from "@/lib/overlay-root";
import { hasActiveWebOverlay, subscribeWebOverlayChanges } from "@/lib/overlay-root";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as Clipboard from "expo-clipboard";
import {
@@ -386,8 +386,8 @@ export function TerminalPane({
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 =
@@ -396,11 +396,32 @@ export function TerminalPane({
!active.matches(".xterm-helper-textarea") &&
active.getClientRects().length > 0;
if (hasActiveWebOverlay() || hasFocusedInput) {
return;
return false;
}
}
requestTerminalFocus();
lastAutoFocusKeyRef.current = focusKey;
return true;
}
if (tryAutoFocus() || !isWeb) return;
let retryFrame: number | null = null;
function stopRetrying(): void {
document.removeEventListener("focusout", scheduleRetry);
unsubscribeOverlayChanges();
if (retryFrame !== null) window.cancelAnimationFrame(retryFrame);
}
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);
return stopRetrying;
}, [
isMobile,
isPaneFocused,
+14
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,17 @@ export function hasActiveWebOverlay(): boolean {
return getTopWebOverlay() !== undefined;
}
export function subscribeWebOverlayChanges(listener: () => void): () => void {
webOverlayChangeListeners.add(listener);
return () => {
webOverlayChangeListeners.delete(listener);
};
}
function notifyWebOverlayChanges(): void {
for (const listener of webOverlayChangeListeners) listener();
}
function getFocusableElements(scope: HTMLElement): HTMLElement[] {
const selector = [
"a[href]",
@@ -214,6 +226,7 @@ function detachWebOverlayListeners(): void {
function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptions) => void {
webOverlayEntries.push(entry);
attachWebOverlayListeners();
notifyWebOverlayChanges();
const focusFrame = window.requestAnimationFrame(() => {
const scope = entry.getScope();
@@ -240,6 +253,7 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
) {
entry.restoreFocus.focus();
}
notifyWebOverlayChanges();
};
}