diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3c132e0c..a1e07319d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- Fixed retained terminals not regaining keyboard focus after switching back to their workspace with a number shortcut. + ## 0.11.0-beta.5 - 2026-10-06 ### Added 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 c3481110f7..9a4b9cc31d 100644 --- a/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts +++ b/packages/app/e2e/browser/terminal-retained-tab-stream.spec.ts @@ -59,4 +59,45 @@ 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 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(); + } + }); }); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 31c36b607c..7344d004a1 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -2,6 +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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Clipboard from "expo-clipboard"; import { @@ -68,7 +69,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 +378,39 @@ 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 (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; + } + } requestTerminalFocus(); } - }, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]); + }, [ + isMobile, + isPaneFocused, + isWorkspaceFocused, + rendererReadyStreamKey, + requestTerminalFocus, + scopeKey, + terminalId, + terminalStreamKey, + ]); useEffect(() => { const canRequest = canRequestFocusClaim({