| author | |
| committer | |
| log | b381fdb12cb7cb241e2b8aca84941375f3fb43f4 |
| tree | 5439871eef925bb12f1988195d321e6edc75db09 |
| parent | e0b4f4639037ce539e6feac7eaf11995cf5490f8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 |
| 2 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | import { RightPanelTabs, surfaceShortcutActionForKey, tabMuteMenuItem } from "./RightPanelTabs"; | |
| 5 | import { | |
| 6 | RightPanelTabs, | |
| 7 | surfaceShortcutActionForKey, | |
| 8 | surfaceShortcutTargetsTypingContext, | |
| 9 | tabMuteMenuItem, | |
| 10 | } from "./RightPanelTabs"; | |
| 6 | 11 | |
| 7 | 12 | function shortcutEvent( |
| 8 | 13 | key: string, |
| ... | ... | @@ -166,6 +171,33 @@ describe("surface shortcuts", () => { |
| 166 | 171 | }); |
| 167 | 172 | }); |
| 168 | 173 | |
| 174 | describe("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 | ||
| 169 | 201 | describe("RightPanelTabs audio indicator", () => { |
| 170 | 202 | // A muted tab only shows the indicator while it is actually making sound: |
| 171 | 203 | // 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< |
| 190 | 190 | ); |
| 191 | 191 | } |
| 192 | 192 | |
| 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 | */ | |
| 201 | export 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 | ||
| 193 | 210 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { |
| 194 | 211 | return ( |
| 195 | 212 | <Tooltip> |
| ... | ... | @@ -329,13 +346,7 @@ function RightPanelEmptyState(props: { |
| 329 | 346 | if (!action) return; |
| 330 | 347 | if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; |
| 331 | 348 | 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; | |
| 339 | 350 | event.preventDefault(); |
| 340 | 351 | event.stopPropagation(); |
| 341 | 352 | action.onClick(); |