From 19e0fd2dd5433fb6730b348714f379a0c1c49672 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:27:31 +0200 Subject: [PATCH 1/3] fix(web): launcher shortcuts no longer hijack the empty composer The right-panel empty-state launcher installed a capture-phase keydown handler that claimed its letters from a focused but empty chat composer, treating at-rest contenteditables as non-typing contexts. Typing a prompt that starts with t, b, f, d, p, or a would instead open that surface - for T, a live terminal that autofocuses and receives the rest of the prompt plus any Enter presses as shell input. Treat every contenteditable as a typing context regardless of content, matching the launcher's own documented contract of working only outside typing contexts. Letters still open surfaces when focus sits elsewhere. ox-alpha via opencode --- .../src/components/RightPanelTabs.test.tsx | 27 ++++++++++++++++++- apps/web/src/components/RightPanelTabs.tsx | 20 +++++++++----- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 1812aa10260b..3ac422458f27 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,26 @@ describe("surface shortcuts", () => { }); }); +describe("surface shortcut typing contexts", () => { + const targetIn = (selector: string | null) => ({ + closest: (selectors: string) => (selector === null ? null : { matches: selector }), + }); + + it("treats form fields and every contenteditable as typing contexts", () => { + expect(surfaceShortcutTargetsTypingContext(targetIn("input"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(targetIn("textarea"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(targetIn("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. + expect(surfaceShortcutTargetsTypingContext(targetIn("[contenteditable]"))).toBe(true); + }); + + it("claims letters when focus sits outside any editable", () => { + expect(surfaceShortcutTargetsTypingContext(targetIn(null))).toBe(false); + expect(surfaceShortcutTargetsTypingContext(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 f48c9ca07e4c..1590c36d7b06 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -190,6 +190,18 @@ 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. + */ +export function surfaceShortcutTargetsTypingContext( + target: { closest(selectors: string): unknown } | null, +): boolean { + return target?.closest("input, textarea, select, [contenteditable]") != null; +} + function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( @@ -329,13 +341,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(); From c0aae95571f0483e3305dd7a942f602c054f699a Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:46:02 +0200 Subject: [PATCH 2/3] fix(web): keep launcher letters working on contenteditable=false islands CodeRabbit review: the [contenteditable] attribute selector also matches contenteditable="false" elements, so chips inside the composer or standalone non-editable widgets would have been treated as typing contexts and lost launcher access. Inspect the nearest editable ancestor's attribute value instead of mere presence, and make the test stub selector-aware so it actually exercises the selector list. ox-alpha via opencode --- .../src/components/RightPanelTabs.test.tsx | 32 +++++++++++++------ apps/web/src/components/RightPanelTabs.tsx | 11 +++++-- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 3ac422458f27..101f16c6441f 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -172,22 +172,36 @@ describe("surface shortcuts", () => { }); describe("surface shortcut typing contexts", () => { - const targetIn = (selector: string | null) => ({ - closest: (selectors: string) => (selector === null ? null : { matches: selector }), + // Selector-aware stub: closest() only answers when the requested selector + // list contains the matched element, like a real DOM would. + const makeTarget = (matches: string | null, contenteditableValue?: string | null) => ({ + closest(selectors: string) { + if (matches === null || !selectors.includes(matches)) return null; + return { + getAttribute: (name: string) => + name === "contenteditable" ? (contenteditableValue ?? null) : null, + }; + }, }); - it("treats form fields and every contenteditable as typing contexts", () => { - expect(surfaceShortcutTargetsTypingContext(targetIn("input"))).toBe(true); - expect(surfaceShortcutTargetsTypingContext(targetIn("textarea"))).toBe(true); - expect(surfaceShortcutTargetsTypingContext(targetIn("select"))).toBe(true); + 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. - expect(surfaceShortcutTargetsTypingContext(targetIn("[contenteditable]"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]", "true"))).toBe(true); + // An empty attribute value still means editable. + expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]", ""))).toBe(true); }); - it("claims letters when focus sits outside any editable", () => { - expect(surfaceShortcutTargetsTypingContext(targetIn(null))).toBe(false); + it("ignores non-editable regions and bare focus targets", () => { expect(surfaceShortcutTargetsTypingContext(null)).toBe(false); + expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false); + // Non-editable islands inside an editable host stay shortcut-reachable. + expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]", "false"))).toBe( + false, + ); }); }); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 1590c36d7b06..a07fe0898bae 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -194,12 +194,17 @@ 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. + * into whatever surface opens. Non-editable islands marked + * `contenteditable="false"` (chips inside the composer, standalone widgets) + * stay shortcut-reachable. */ export function surfaceShortcutTargetsTypingContext( - target: { closest(selectors: string): unknown } | null, + target: { + closest(selectors: string): { getAttribute(qualifiedName: string): string | null } | null; + } | null, ): boolean { - return target?.closest("input, textarea, select, [contenteditable]") != null; + const editable = target?.closest("input, textarea, select, [contenteditable]") ?? null; + return editable != null && editable.getAttribute("contenteditable") !== "false"; } function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { From ce7e61408f302a0c50bf92db65533828b63933bf Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:08:56 +0200 Subject: [PATCH 3/3] fix(web): see past contenteditable=false islands when guarding launcher keys Macroscope review: the nearest-match-then-check-attribute shape misclassified genuinely editable regions nested inside a contenteditable="false" island inside the composer, re-opening the keystroke hijacking this PR fixes. Adopt the repo's existing typing guard idiom from ComposerPendingUserInputPanel: [contenteditable]:not([contenteditable="false"]) lets closest reach the editable host around non-editable islands while standalone false islands stay shortcut-reachable. ox-alpha via opencode --- .../src/components/RightPanelTabs.test.tsx | 23 +++++++------------ apps/web/src/components/RightPanelTabs.tsx | 16 ++++++------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 101f16c6441f..7b0ae9b4c201 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -172,15 +172,12 @@ describe("surface shortcuts", () => { }); describe("surface shortcut typing contexts", () => { - // Selector-aware stub: closest() only answers when the requested selector - // list contains the matched element, like a real DOM would. - const makeTarget = (matches: string | null, contenteditableValue?: string | null) => ({ + // 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 { - getAttribute: (name: string) => - name === "contenteditable" ? (contenteditableValue ?? null) : null, - }; + return {}; }, }); @@ -190,18 +187,14 @@ describe("surface shortcut typing contexts", () => { 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. - expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]", "true"))).toBe(true); - // An empty attribute value still means editable. - expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]", ""))).toBe(true); + // 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("ignores non-editable regions and bare focus targets", () => { + it("claims letters when focus sits outside any editable region", () => { expect(surfaceShortcutTargetsTypingContext(null)).toBe(false); expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false); - // Non-editable islands inside an editable host stay shortcut-reachable. - expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]", "false"))).toBe( - false, - ); }); }); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index a07fe0898bae..5cc421db3542 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -194,17 +194,17 @@ 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. Non-editable islands marked - * `contenteditable="false"` (chips inside the composer, standalone widgets) - * stay shortcut-reachable. + * 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): { getAttribute(qualifiedName: string): string | null } | null; - } | null, + target: { closest(selectors: string): unknown } | null, ): boolean { - const editable = target?.closest("input, textarea, select, [contenteditable]") ?? null; - return editable != null && editable.getAttribute("contenteditable") !== "false"; + return ( + target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != + null + ); } function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {