From ce8ca5bb3d005d8653d1b09c1e7c9e02d3ef8ae4 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:10:51 +0200 Subject: [PATCH] fix(web): hide thread jump hints while the terminal is focused (#7277) --- apps/web/src/components/LegacySidebar.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 11 +++++- apps/web/src/hooks/useTerminalFocus.test.ts | 42 +++++++++++++++++++++ apps/web/src/hooks/useTerminalFocus.ts | 16 ++++++++ apps/web/src/keybindings.test.ts | 15 ++++++++ apps/web/src/keybindings.ts | 10 +++++ 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/hooks/useTerminalFocus.test.ts create mode 100644 apps/web/src/hooks/useTerminalFocus.ts diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index dbfa1babd6bf4824d6e2a4887537fb56b8dcd668..b4f3b5996818e1d0aded75d44a72f52ff6439aae 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -73,6 +73,7 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; +import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -3075,6 +3076,7 @@ export default function LegacySidebar() { const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); + const terminalFocused = useTerminalFocus(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const environmentLabelById = useMemo( @@ -3397,7 +3399,7 @@ export default function LegacySidebar() { [threadJumpCommandByKey], ); const sidebarShortcutContext = { - terminalFocus: false, + terminalFocus: terminalFocused, terminalOpen: routeTerminalOpen, modelPickerOpen: isModelPickerOpen(), }; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d64a6a2b760c11d69905ae65c545c8e5925d6b41..a88bc9ce4bb75f09687967231bc100c861b3ac0d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -83,6 +83,7 @@ import { threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; +import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -3312,10 +3313,18 @@ export default function Sidebar() { // match a thread-jump binding. Adding Shift (screenshots) or Alt no // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. const shortcutModifiers = useShortcutModifierState(); + const terminalFocused = useTerminalFocus(); const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( shortcutModifiers, keybindings, - { platform: navigator.platform }, + { + platform: navigator.platform, + context: { + terminalFocus: terminalFocused, + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }, ); useEffect(() => { setShowJumpHints(shouldShowJumpHintsNow); diff --git a/apps/web/src/hooks/useTerminalFocus.test.ts b/apps/web/src/hooks/useTerminalFocus.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..60d9487416534dc2ac5103ebaa2b37c8adb3d126 --- /dev/null +++ b/apps/web/src/hooks/useTerminalFocus.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { subscribeToTerminalFocusChanges } from "./useTerminalFocus"; + +const originalWindow = globalThis.window; + +afterEach(() => { + if (originalWindow === undefined) { + delete (globalThis as { window?: Window }).window; + } else { + globalThis.window = originalWindow; + } +}); + +describe("subscribeToTerminalFocusChanges", () => { + it("notifies on focus transitions until unsubscribed", () => { + const listeners = new Map void>>(); + globalThis.window = { + addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + const callbacks = listeners.get(type) ?? new Set<() => void>(); + callbacks.add(listener as () => void); + listeners.set(type, callbacks); + }, + removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + listeners.get(type)?.delete(listener as () => void); + }, + } as unknown as Window & typeof globalThis; + let notifications = 0; + + const unsubscribe = subscribeToTerminalFocusChanges(() => { + notifications += 1; + }); + + for (const listener of listeners.get("focusin") ?? []) listener(); + for (const listener of listeners.get("focusout") ?? []) listener(); + expect(notifications).toBe(2); + + unsubscribe(); + for (const listener of listeners.get("focusin") ?? []) listener(); + expect(notifications).toBe(2); + }); +}); diff --git a/apps/web/src/hooks/useTerminalFocus.ts b/apps/web/src/hooks/useTerminalFocus.ts new file mode 100644 index 0000000000000000000000000000000000000000..84c897ffc80fce8bfecfe603cd9fd6a10a1d6cbf --- /dev/null +++ b/apps/web/src/hooks/useTerminalFocus.ts @@ -0,0 +1,16 @@ +import { useSyncExternalStore } from "react"; + +import { isTerminalFocused } from "../lib/terminalFocus"; + +export function subscribeToTerminalFocusChanges(listener: () => void): () => void { + window.addEventListener("focusin", listener, true); + window.addEventListener("focusout", listener, true); + return () => { + window.removeEventListener("focusin", listener, true); + window.removeEventListener("focusout", listener, true); + }; +} + +export function useTerminalFocus(): boolean { + return useSyncExternalStore(subscribeToTerminalFocusChanges, isTerminalFocused, () => false); +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 6ac53a52f18af05e60008dab76752175212e2142..7d07610c643038c56b61b230cbaedf785f4684dd 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -451,6 +451,21 @@ describe("thread navigation helpers", () => { }), ); }); + + it("never shows jump hints while the terminal is focused, even with an unrestricted binding", () => { + assert.isFalse( + shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + assert.isTrue( + shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + }); }); describe("model picker navigation helpers", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 6ec9a6fab858a7f8cb940755d124367defd3a05d..eb4637df21bed404c6b35322879da73db3f87697 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -301,6 +301,16 @@ export function shouldShowThreadJumpHintsForModifiers( keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, ): boolean { + // The embedded terminal owns keystrokes while it has focus: the Ghostty + // surface encodes the keydown and can write the pressed key into the shell + // before our window-level shortcut handling ever runs, regardless of any + // configured `when` clause on the jump command. Advertising jump hints + // here would promise a shortcut that instead types into the terminal, so + // never show them while the terminal is focused. + if (resolveContext(options).terminalFocus) { + return false; + } + const platform = resolvePlatform(options); for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) { -- 2.54.0