authorgravatar for lucenz@proton.meSimone <lucenz@proton.me> 2026-08-22 00:02:32+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 18:02:32-04:00
logb381fdb12cb7cb241e2b8aca84941375f3fb43f4
tree5439871eef925bb12f1988195d321e6edc75db09
parente0b4f4639037ce539e6feac7eaf11995cf5490f8
signature Signed by PGP key B5690EEEBB952194

fix(web): launcher shortcuts no longer hijack the empty composer (#7794)

Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>

2 files changed, 51 insertions(+), 8 deletions(-)

apps/web/src/components/RightPanelTabs.test.tsx+33-1
......@@ -2,7 +2,12 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con
22import { renderToStaticMarkup } from "react-dom/server";
33import { describe, expect, it } from "vite-plus/test";
44
5import { RightPanelTabs, surfaceShortcutActionForKey, tabMuteMenuItem } from "./RightPanelTabs";
5import {
6 RightPanelTabs,
7 surfaceShortcutActionForKey,
8 surfaceShortcutTargetsTypingContext,
9 tabMuteMenuItem,
10} from "./RightPanelTabs";
611
712function shortcutEvent(
813 key: string,
......@@ -166,6 +171,33 @@ describe("surface shortcuts", () => {
166171 });
167172});
168173
174describe("surface shortcut typing contexts", () => {
175 // Selector-aware stub: closest() answers only tokens the combined selector
176 // would actually match, mirroring how the browser resolves it.
177 const makeTarget = (matches: string | null) => ({
178 closest(selectors: string) {
179 if (matches === null || !selectors.includes(matches)) return null;
180 return {};
181 },
182 });
183
184 it("treats form fields and every editable region as typing contexts", () => {
185 expect(surfaceShortcutTargetsTypingContext(makeTarget("input"))).toBe(true);
186 expect(surfaceShortcutTargetsTypingContext(makeTarget("textarea"))).toBe(true);
187 expect(surfaceShortcutTargetsTypingContext(makeTarget("select"))).toBe(true);
188 // The chat composer is a contenteditable that sits empty until a draft
189 // exists; launcher letters claimed from it redirected prompts into shells.
190 // The :not clause sees past contenteditable="false" islands to an editable
191 // host around them, so nested editors stay protected too.
192 expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]"))).toBe(true);
193 });
194
195 it("claims letters when focus sits outside any editable region", () => {
196 expect(surfaceShortcutTargetsTypingContext(null)).toBe(false);
197 expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false);
198 });
199});
200
169201describe("RightPanelTabs audio indicator", () => {
170202 // A muted tab only shows the indicator while it is actually making sound:
171203 // arming mute on a quiet tab is deliberate and stays invisible until there
apps/web/src/components/RightPanelTabs.tsx+18-7
......@@ -190,6 +190,23 @@ export function surfaceShortcutActionForKey<
190190 );
191191}
192192
193/**
194 * A focused editable is a typing context whether or not it has text yet: an
195 * empty chat composer at rest is still where the user's next keystrokes are
196 * meant to land, and claiming launcher letters from it would redirect prompts
197 * into whatever surface opens. The `:not` clause lets `closest` see past
198 * non-editable islands (`contenteditable="false"`) to an editable host around
199 * them, matching ComposerPendingUserInputPanel's typing guard.
200 */
201export function surfaceShortcutTargetsTypingContext(
202 target: { closest(selectors: string): unknown } | null,
203): boolean {
204 return (
205 target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !=
206 null
207 );
208}
209
193210function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
194211 return (
195212 <Tooltip>
......@@ -329,13 +346,7 @@ function RightPanelEmptyState(props: {
329346 if (!action) return;
330347 if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
331348 const target = event.target;
332 if (target instanceof HTMLElement) {
333 if (target.closest("input, textarea, select")) return;
334 // An empty contenteditable (the chat composer at rest) does not
335 // count as typing; letters only become text once a draft exists.
336 const editable = target.isContentEditable ? target : target.closest("[contenteditable]");
337 if (editable && (editable.textContent ?? "").trim().length > 0) return;
338 }
349 if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return;
339350 event.preventDefault();
340351 event.stopPropagation();
341352 action.onClick();