diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e07319d9..a2191a65e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts b/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts index 9a4b9cc31d..8a46d9b1b4 100644 --- a/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts +++ b/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts @@ -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 = []; - 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", + }); }); }); diff --git a/packages/app/e2e/support/helpers/terminal-dsl.ts b/packages/app/e2e/support/helpers/terminal-dsl.ts index bf16276464..c9698a14c3 100644 --- a/packages/app/e2e/support/helpers/terminal-dsl.ts +++ b/packages/app/e2e/support/helpers/terminal-dsl.ts @@ -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 { + 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 { + const workspace = await TerminalE2EHarness.create({ tempPrefix: "terminal-workspace-focus-" }); + this.otherWorkspaces.push(workspace); + return workspace; + } + + async switchToWorkspaceByShortcut(page: Page): Promise { + 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 { + // 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 { + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`/workspace/${this.workspaceId}`)); + } + + async expectTerminalFocused(page: Page): Promise { + 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 { + 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"]'); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 7344d004a1..6c31fe07c1 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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, diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 9ffd81725f..0043456509 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -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(); }; }