| author | |
| committer | |
| log | 3c4c9a125bf6e3dd7207fff23b4bf3d449e4df36 |
| tree | 55ed6daf6f13fc58385960e89f88b748160d1ad0 |
| parent | 50ff4c371eab927a9650c114975241999f4cd7b1 |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 76 insertions(+), 7 deletions(-)
apps/web/src/components/BranchToolbar.tsx+2-1| ... | ... | @@ -54,7 +54,7 @@ import { |
| 54 | 54 | } from "./ui/menu"; |
| 55 | 55 | import { Separator } from "./ui/separator"; |
| 56 | 56 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 57 | import { composerFloatingLayerProps } from "./chat/composerEventScope"; | |
| 57 | import { useComposerMenuProps } from "./chat/composerEventScope"; | |
| 58 | 58 | import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; |
| 59 | 59 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; |
| 60 | 60 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -120,6 +120,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 120 | 120 | previousWorktreeLabel, |
| 121 | 121 | onUsePreviousWorktree, |
| 122 | 122 | }: MobileRunContextSelectorProps) { |
| 123 | const composerFloatingLayerProps = useComposerMenuProps(); | |
| 123 | 124 | const activeEnvironment = useMemo( |
| 124 | 125 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, |
| 125 | 126 | [availableEnvironments, environmentId], |
apps/web/src/components/BranchToolbarBranchSelector.tsx+2-1| ... | ... | @@ -40,7 +40,7 @@ import { vcsEnvironment } from "../state/vcs"; |
| 40 | 40 | import { cn } from "../lib/utils"; |
| 41 | 41 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 42 | 42 | import { getSourceControlPresentation } from "../sourceControlPresentation"; |
| 43 | import { composerFloatingLayerProps } from "./chat/composerEventScope"; | |
| 43 | import { useComposerMenuProps } from "./chat/composerEventScope"; | |
| 44 | 44 | import { |
| 45 | 45 | deriveLocalBranchNameFromRemoteRef, |
| 46 | 46 | resolveBranchTriggerLabel, |
| ... | ... | @@ -113,6 +113,7 @@ export function BranchToolbarBranchSelector({ |
| 113 | 113 | onCheckoutPullRequestRequest, |
| 114 | 114 | onComposerFocusRequest, |
| 115 | 115 | }: BranchToolbarBranchSelectorProps) { |
| 116 | const composerFloatingLayerProps = useComposerMenuProps(); | |
| 116 | 117 | const startFromOriginSwitchId = useId(); |
| 117 | 118 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); |
| 118 | 119 | const updateThreadMetadata = useAtomCommand( |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+2-1| ... | ... | @@ -7,7 +7,7 @@ import { |
| 7 | 7 | resolveLockedWorkspaceLabel, |
| 8 | 8 | type EnvMode, |
| 9 | 9 | } from "./BranchToolbar.logic"; |
| 10 | import { composerFloatingLayerProps } from "./chat/composerEventScope"; | |
| 10 | import { useComposerMenuProps } from "./chat/composerEventScope"; | |
| 11 | 11 | import { |
| 12 | 12 | Select, |
| 13 | 13 | SelectGroup, |
| ... | ... | @@ -37,6 +37,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 37 | 37 | previousWorktreeLabel, |
| 38 | 38 | onUsePreviousWorktree, |
| 39 | 39 | }: BranchToolbarEnvModeSelectorProps) { |
| 40 | const composerFloatingLayerProps = useComposerMenuProps(); | |
| 40 | 41 | const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); |
| 41 | 42 | const envModeItems = useMemo( |
| 42 | 43 | () => [ |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+2-1| ... | ... | @@ -4,7 +4,7 @@ import { memo, useMemo } from "react"; |
| 4 | 4 | |
| 5 | 5 | import type { EnvironmentOption } from "./BranchToolbar.logic"; |
| 6 | 6 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 7 | import { composerFloatingLayerProps } from "./chat/composerEventScope"; | |
| 7 | import { useComposerMenuProps } from "./chat/composerEventScope"; | |
| 8 | 8 | import { |
| 9 | 9 | Select, |
| 10 | 10 | SelectGroup, |
| ... | ... | @@ -34,6 +34,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 34 | 34 | availableEnvironments, |
| 35 | 35 | onEnvironmentChange, |
| 36 | 36 | }: BranchToolbarEnvironmentSelectorProps) { |
| 37 | const composerFloatingLayerProps = useComposerMenuProps(); | |
| 37 | 38 | const activeEnvironment = useMemo(() => { |
| 38 | 39 | return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; |
| 39 | 40 | }, [availableEnvironments, environmentId]); |
apps/web/src/components/chat/ChatComposer.tsx+2| ... | ... | @@ -90,6 +90,7 @@ import { |
| 90 | 90 | } from "./composerMentionDrag"; |
| 91 | 91 | import { |
| 92 | 92 | composerFloatingLayerProps, |
| 93 | useComposerMenuProps, | |
| 93 | 94 | isInsideCollapsedComposerControls, |
| 94 | 95 | isInsideComposerFloatingLayer, |
| 95 | 96 | isInsideRestingComposerControlScope, |
| ... | ... | @@ -1038,6 +1039,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 1038 | 1039 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 1039 | 1040 | }) { |
| 1040 | 1041 | const size = props.size ?? "sm"; |
| 1042 | const composerFloatingLayerProps = useComposerMenuProps(); | |
| 1041 | 1043 | const [open, setOpen] = useComposerMenuState(props.hidden); |
| 1042 | 1044 | const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; |
| 1043 | 1045 | const RuntimeModeIcon = runtimeModeOption.icon; |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+2-1| ... | ... | @@ -10,7 +10,7 @@ import { |
| 10 | 10 | MenuTrigger, |
| 11 | 11 | } from "../ui/menu"; |
| 12 | 12 | import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; |
| 13 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 13 | import { useComposerMenuProps } from "./composerEventScope"; | |
| 14 | 14 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 15 | 15 | |
| 16 | 16 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { |
| ... | ... | @@ -28,6 +28,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 28 | 28 | onToggleInteractionMode: () => void; |
| 29 | 29 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 30 | 30 | }) { |
| 31 | const composerFloatingLayerProps = useComposerMenuProps(); | |
| 31 | 32 | const size = props.size ?? "sm"; |
| 32 | 33 | const [open, setOpen] = useComposerMenuState(props.hidden); |
| 33 | 34 |
apps/web/src/components/chat/ProviderModelPicker.tsx+2-1| ... | ... | @@ -24,7 +24,7 @@ import { |
| 24 | 24 | ComposerControlChevron, |
| 25 | 25 | type ComposerControlSize, |
| 26 | 26 | } from "./ComposerControl"; |
| 27 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 27 | import { useComposerMenuProps } from "./composerEventScope"; | |
| 28 | 28 | |
| 29 | 29 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 30 | 30 | /** |
| ... | ... | @@ -56,6 +56,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 56 | 56 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; |
| 57 | 57 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| 58 | 58 | }) { |
| 59 | const composerFloatingLayerProps = useComposerMenuProps(); | |
| 59 | 60 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); |
| 60 | 61 | const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; |
| 61 | 62 | const size = props.size ?? "sm"; |
apps/web/src/components/chat/TraitsPicker.tsx+2-1| ... | ... | @@ -38,7 +38,7 @@ import { |
| 38 | 38 | ComposerControlIcon, |
| 39 | 39 | type ComposerControlSize, |
| 40 | 40 | } from "./ComposerControl"; |
| 41 | import { composerFloatingLayerProps } from "./composerEventScope"; | |
| 41 | import { useComposerMenuProps } from "./composerEventScope"; | |
| 42 | 42 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 43 | 43 | |
| 44 | 44 | type ProviderOptions = ReadonlyArray<ProviderOptionSelection>; |
| ... | ... | @@ -557,6 +557,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 557 | 557 | size?: ComposerControlSize; |
| 558 | 558 | hidden?: boolean; |
| 559 | 559 | }) { |
| 560 | const composerFloatingLayerProps = useComposerMenuProps(); | |
| 560 | 561 | const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden); |
| 561 | 562 | const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = |
| 562 | 563 | getTraitsSectionVisibility({ |
apps/web/src/components/chat/composerEventScope.test.ts+40| ... | ... | @@ -1,9 +1,13 @@ |
| 1 | import { act, createElement, useLayoutEffect } from "react"; | |
| 2 | import { create } from "react-test-renderer"; | |
| 1 | 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 4 | |
| 5 | import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; | |
| 3 | 6 | import { |
| 4 | 7 | isInsideCollapsedComposerControls, |
| 5 | 8 | isInsideComposerFloatingLayer, |
| 6 | 9 | isInsideRestingComposerControlScope, |
| 10 | useComposerMenuProps, | |
| 7 | 11 | } from "./composerEventScope"; |
| 8 | 12 | |
| 9 | 13 | class FakeElement { |
| ... | ... | @@ -21,6 +25,42 @@ afterEach(() => { |
| 21 | 25 | vi.unstubAllGlobals(); |
| 22 | 26 | }); |
| 23 | 27 | |
| 28 | describe("composer menu focus", () => { | |
| 29 | it.each([ | |
| 30 | ["an open menu", '[data-chat-composer-floating-layer="true"]', true], | |
| 31 | ["an unmounted menu", null, true], | |
| 32 | ["another control", "input", false], | |
| 33 | ])("closes while focus is on %s", async (_label, selector, shouldFocusComposer) => { | |
| 34 | const body = new FakeElement(null); | |
| 35 | const editor = new FakeElement(null); | |
| 36 | const activeElement = selector === null ? body : new FakeElement(selector); | |
| 37 | const document = { body, activeElement }; | |
| 38 | const composerRef = { | |
| 39 | current: { focusAtEnd: () => (document.activeElement = editor) }, | |
| 40 | } as unknown as ComposerHandleRef; | |
| 41 | vi.stubGlobal("Element", FakeElement); | |
| 42 | vi.stubGlobal("document", document); | |
| 43 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 44 | let menuProps: ReturnType<typeof useComposerMenuProps> | undefined; | |
| 45 | function Probe() { | |
| 46 | const props = useComposerMenuProps(); | |
| 47 | useLayoutEffect(() => { | |
| 48 | menuProps = props; | |
| 49 | }, [props]); | |
| 50 | return null; | |
| 51 | } | |
| 52 | const renderer = await act(() => | |
| 53 | create(createElement(ComposerHandleContext, { value: composerRef }, createElement(Probe))), | |
| 54 | ); | |
| 55 | try { | |
| 56 | expect(menuProps?.finalFocus?.()).toBe(false); | |
| 57 | expect(document.activeElement).toBe(shouldFocusComposer ? editor : activeElement); | |
| 58 | } finally { | |
| 59 | await act(() => renderer.unmount()); | |
| 60 | } | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 24 | 64 | describe("composer event scopes", () => { |
| 25 | 65 | it("recognizes events from the portaled resting controls", () => { |
| 26 | 66 | vi.stubGlobal("Element", FakeElement); |
apps/web/src/components/chat/composerEventScope.ts+20| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { useComposerHandleContext } from "../../composerHandleContext"; | |
| 2 | ||
| 1 | 3 | const COMPOSER_FLOATING_LAYER_SELECTOR = [ |
| 2 | 4 | '[data-composer-drawer-layer="true"]', |
| 3 | 5 | '[data-chat-composer-floating-layer="true"]', |
| ... | ... | @@ -7,6 +9,24 @@ export const composerFloatingLayerProps = { |
| 7 | 9 | "data-chat-composer-floating-layer": "true", |
| 8 | 10 | } as const; |
| 9 | 11 | |
| 12 | export function useComposerMenuProps() { | |
| 13 | const composerRef = useComposerHandleContext(); | |
| 14 | ||
| 15 | return { | |
| 16 | ...composerFloatingLayerProps, | |
| 17 | finalFocus: composerRef | |
| 18 | ? () => { | |
| 19 | const activeElement = document.activeElement; | |
| 20 | if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { | |
| 21 | return false; | |
| 22 | } | |
| 23 | composerRef.current?.focusAtEnd(); | |
| 24 | return false; | |
| 25 | } | |
| 26 | : undefined, | |
| 27 | }; | |
| 28 | } | |
| 29 | ||
| 10 | 30 | export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean { |
| 11 | 31 | return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; |
| 12 | 32 | } |