diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 1812aa10260..7b0ae9b4c20 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,7 +2,12 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { RightPanelTabs, surfaceShortcutActionForKey, tabMuteMenuItem } from "./RightPanelTabs"; +import { + RightPanelTabs, + surfaceShortcutActionForKey, + surfaceShortcutTargetsTypingContext, + tabMuteMenuItem, +} from "./RightPanelTabs"; function shortcutEvent( key: string, @@ -166,6 +171,33 @@ describe("surface shortcuts", () => { }); }); +describe("surface shortcut typing contexts", () => { + // Selector-aware stub: closest() answers only tokens the combined selector + // would actually match, mirroring how the browser resolves it. + const makeTarget = (matches: string | null) => ({ + closest(selectors: string) { + if (matches === null || !selectors.includes(matches)) return null; + return {}; + }, + }); + + it("treats form fields and every editable region as typing contexts", () => { + expect(surfaceShortcutTargetsTypingContext(makeTarget("input"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("textarea"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("select"))).toBe(true); + // The chat composer is a contenteditable that sits empty until a draft + // exists; launcher letters claimed from it redirected prompts into shells. + // The :not clause sees past contenteditable="false" islands to an editable + // host around them, so nested editors stay protected too. + expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]"))).toBe(true); + }); + + it("claims letters when focus sits outside any editable region", () => { + expect(surfaceShortcutTargetsTypingContext(null)).toBe(false); + expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false); + }); +}); + describe("RightPanelTabs audio indicator", () => { // A muted tab only shows the indicator while it is actually making sound: // arming mute on a quiet tab is deliberate and stays invisible until there diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f48c9ca07e4..5cc421db354 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -190,6 +190,23 @@ export function surfaceShortcutActionForKey< ); } +/** + * A focused editable is a typing context whether or not it has text yet: an + * empty chat composer at rest is still where the user's next keystrokes are + * meant to land, and claiming launcher letters from it would redirect prompts + * into whatever surface opens. The `:not` clause lets `closest` see past + * non-editable islands (`contenteditable="false"`) to an editable host around + * them, matching ComposerPendingUserInputPanel's typing guard. + */ +export function surfaceShortcutTargetsTypingContext( + target: { closest(selectors: string): unknown } | null, +): boolean { + return ( + target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != + null + ); +} + function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( @@ -329,13 +346,7 @@ function RightPanelEmptyState(props: { if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; const target = event.target; - if (target instanceof HTMLElement) { - if (target.closest("input, textarea, select")) return; - // An empty contenteditable (the chat composer at rest) does not - // count as typing; letters only become text once a draft exists. - const editable = target.isContentEditable ? target : target.closest("[contenteditable]"); - if (editable && (editable.textContent ?? "").trim().length > 0) return; - } + if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); action.onClick();