mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
fix(app): retry deferred terminal focus after controls release
This commit is contained in:
+1
-1
@@ -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"]');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user