diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 7b7ffe626bc817fe338a87e88d9515f43bd974a6..d36eabc3cec5578a2782bf743249ea47616c6c06 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -54,7 +54,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { ComposerSurface } from "./chat/ComposerSurface"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; @@ -120,6 +120,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ previousWorktreeLabel, onUsePreviousWorktree, }: MobileRunContextSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, [availableEnvironments, environmentId], diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 5f35c14de4158ff4c73affc7dce839d30e54f6dc..ba5251538685a40cc3de36746e7d657d45c63985 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -40,7 +40,7 @@ import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, @@ -113,6 +113,7 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const startFromOriginSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index a350931fbf45b80541c643b916b6eedad945b874..b29c77cb378d4819ce1aa98e3152333559fc2729 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -7,7 +7,7 @@ import { resolveLockedWorkspaceLabel, type EnvMode, } from "./BranchToolbar.logic"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import { Select, SelectGroup, @@ -37,6 +37,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe previousWorktreeLabel, onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 0fa879c78a551ebe1c1003abf6a228419412cea8..231e4b0d824d64a9a7624990b3431b05dc321fe5 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -4,7 +4,7 @@ import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import { Select, SelectGroup, @@ -34,6 +34,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir availableEnvironments, onEnvironmentChange, }: BranchToolbarEnvironmentSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = useMemo(() => { return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; }, [availableEnvironments, environmentId]); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5bcdb05dbfdf90007065160c767ac1902de40d9d..f7d3ac540cccf7824ba52906bfab96eda5c502f0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -90,6 +90,7 @@ import { } from "./composerMentionDrag"; import { composerFloatingLayerProps, + useComposerMenuProps, isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, @@ -1038,6 +1039,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const size = props.size ?? "sm"; + const composerFloatingLayerProps = useComposerMenuProps(); const [open, setOpen] = useComposerMenuState(props.hidden); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 78cfb7b8d8fade8cc4c96db6c733ab2ea10bb8aa..1969e2e9de360695777113f0dd5012f123fb3b44 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -10,7 +10,7 @@ import { MenuTrigger, } from "../ui/menu"; import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; -import { composerFloatingLayerProps } from "./composerEventScope"; +import { useComposerMenuProps } from "./composerEventScope"; import { useComposerMenuState } from "./useComposerMenuState"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { @@ -28,6 +28,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { + const composerFloatingLayerProps = useComposerMenuProps(); const size = props.size ?? "sm"; const [open, setOpen] = useComposerMenuState(props.hidden); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6f777399451d0a0e4a8fc60e07207bbe8731ee8e..bc8e7b48ce9651f7f70470be2c19a841bc00f7f9 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -24,7 +24,7 @@ import { ComposerControlChevron, type ComposerControlSize, } from "./ComposerControl"; -import { composerFloatingLayerProps } from "./composerEventScope"; +import { useComposerMenuProps } from "./composerEventScope"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** @@ -56,6 +56,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { + const composerFloatingLayerProps = useComposerMenuProps(); const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; const size = props.size ?? "sm"; diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 0a863e0bd4462ebe466a09fa347744278b753fe1..da293c38ddc9d8f67dba8f94e1d452bfab0c9812 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -38,7 +38,7 @@ import { ComposerControlIcon, type ComposerControlSize, } from "./ComposerControl"; -import { composerFloatingLayerProps } from "./composerEventScope"; +import { useComposerMenuProps } from "./composerEventScope"; import { useComposerMenuState } from "./useComposerMenuState"; type ProviderOptions = ReadonlyArray; @@ -557,6 +557,7 @@ export const TraitsPicker = memo(function TraitsPicker({ size?: ComposerControlSize; hidden?: boolean; }) { + const composerFloatingLayerProps = useComposerMenuProps(); const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden); const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ diff --git a/apps/web/src/components/chat/composerEventScope.test.ts b/apps/web/src/components/chat/composerEventScope.test.ts index 365c5304aa2dd33741aca3daa451a50e4b26a24e..f40c9a68e6eea888b65e047d87d1ad1a9f6417cb 100644 --- a/apps/web/src/components/chat/composerEventScope.test.ts +++ b/apps/web/src/components/chat/composerEventScope.test.ts @@ -1,9 +1,13 @@ +import { act, createElement, useLayoutEffect } from "react"; +import { create } from "react-test-renderer"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; import { isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, + useComposerMenuProps, } from "./composerEventScope"; class FakeElement { @@ -21,6 +25,42 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("composer menu focus", () => { + it.each([ + ["an open menu", '[data-chat-composer-floating-layer="true"]', true], + ["an unmounted menu", null, true], + ["another control", "input", false], + ])("closes while focus is on %s", async (_label, selector, shouldFocusComposer) => { + const body = new FakeElement(null); + const editor = new FakeElement(null); + const activeElement = selector === null ? body : new FakeElement(selector); + const document = { body, activeElement }; + const composerRef = { + current: { focusAtEnd: () => (document.activeElement = editor) }, + } as unknown as ComposerHandleRef; + vi.stubGlobal("Element", FakeElement); + vi.stubGlobal("document", document); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let menuProps: ReturnType | undefined; + function Probe() { + const props = useComposerMenuProps(); + useLayoutEffect(() => { + menuProps = props; + }, [props]); + return null; + } + const renderer = await act(() => + create(createElement(ComposerHandleContext, { value: composerRef }, createElement(Probe))), + ); + try { + expect(menuProps?.finalFocus?.()).toBe(false); + expect(document.activeElement).toBe(shouldFocusComposer ? editor : activeElement); + } finally { + await act(() => renderer.unmount()); + } + }); +}); + describe("composer event scopes", () => { it("recognizes events from the portaled resting controls", () => { vi.stubGlobal("Element", FakeElement); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 88e24fd89422977d4ced41785f2b007405189e5a..391c468d0d033e4fdcf7d74aefd1f78e65818da8 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -1,3 +1,5 @@ +import { useComposerHandleContext } from "../../composerHandleContext"; + const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-composer-drawer-layer="true"]', '[data-chat-composer-floating-layer="true"]', @@ -7,6 +9,24 @@ export const composerFloatingLayerProps = { "data-chat-composer-floating-layer": "true", } as const; +export function useComposerMenuProps() { + const composerRef = useComposerHandleContext(); + + return { + ...composerFloatingLayerProps, + finalFocus: composerRef + ? () => { + const activeElement = document.activeElement; + if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { + return false; + } + composerRef.current?.focusAtEnd(); + return false; + } + : undefined, + }; +} + export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean { return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; }