| 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,7 +54,7 @@ import { |
| 54 | } from "./ui/menu"; | 54 | } from "./ui/menu"; |
| 55 | import { Separator } from "./ui/separator"; | 55 | import { Separator } from "./ui/separator"; |
| 56 | import { ComposerSurface } from "./chat/ComposerSurface"; | 56 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 57 | import { composerFloatingLayerProps } from "./chat/composerEventScope"; | 57 | import { useComposerMenuProps } from "./chat/composerEventScope"; |
| 58 | import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; | 58 | import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; |
| 59 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; | 59 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; |
| 60 | import { cn } from "~/lib/utils"; | 60 | import { cn } from "~/lib/utils"; |
| ... | @@ -120,6 +120,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -120,6 +120,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 120 | previousWorktreeLabel, | 120 | previousWorktreeLabel, |
| 121 | onUsePreviousWorktree, | 121 | onUsePreviousWorktree, |
| 122 | }: MobileRunContextSelectorProps) { | 122 | }: MobileRunContextSelectorProps) { |
| 123 | const composerFloatingLayerProps = useComposerMenuProps(); | ||
| 123 | const activeEnvironment = useMemo( | 124 | const activeEnvironment = useMemo( |
| 124 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, | 125 | () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, |
| 125 | [availableEnvironments, environmentId], | 126 | [availableEnvironments, environmentId], |
apps/web/src/components/BranchToolbarBranchSelector.tsx+2-1| ... | @@ -40,7 +40,7 @@ import { vcsEnvironment } from "../state/vcs"; | ... | @@ -40,7 +40,7 @@ import { vcsEnvironment } from "../state/vcs"; |
| 40 | import { cn } from "../lib/utils"; | 40 | import { cn } from "../lib/utils"; |
| 41 | import { parsePullRequestReference } from "../pullRequestReference"; | 41 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 42 | import { getSourceControlPresentation } from "../sourceControlPresentation"; | 42 | import { getSourceControlPresentation } from "../sourceControlPresentation"; |
| 43 | import { composerFloatingLayerProps } from "./chat/composerEventScope"; | 43 | import { useComposerMenuProps } from "./chat/composerEventScope"; |
| 44 | import { | 44 | import { |
| 45 | deriveLocalBranchNameFromRemoteRef, | 45 | deriveLocalBranchNameFromRemoteRef, |
| 46 | resolveBranchTriggerLabel, | 46 | resolveBranchTriggerLabel, |
| ... | @@ -113,6 +113,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -113,6 +113,7 @@ export function BranchToolbarBranchSelector({ |
| 113 | onCheckoutPullRequestRequest, | 113 | onCheckoutPullRequestRequest, |
| 114 | onComposerFocusRequest, | 114 | onComposerFocusRequest, |
| 115 | }: BranchToolbarBranchSelectorProps) { | 115 | }: BranchToolbarBranchSelectorProps) { |
| 116 | const composerFloatingLayerProps = useComposerMenuProps(); | ||
| 116 | const startFromOriginSwitchId = useId(); | 117 | const startFromOriginSwitchId = useId(); |
| 117 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); | 118 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); |
| 118 | const updateThreadMetadata = useAtomCommand( | 119 | const updateThreadMetadata = useAtomCommand( |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+2-1| ... | @@ -7,7 +7,7 @@ import { | ... | @@ -7,7 +7,7 @@ import { |
| 7 | resolveLockedWorkspaceLabel, | 7 | resolveLockedWorkspaceLabel, |
| 8 | type EnvMode, | 8 | type EnvMode, |
| 9 | } from "./BranchToolbar.logic"; | 9 | } from "./BranchToolbar.logic"; |
| 10 | import { composerFloatingLayerProps } from "./chat/composerEventScope"; | 10 | import { useComposerMenuProps } from "./chat/composerEventScope"; |
| 11 | import { | 11 | import { |
| 12 | Select, | 12 | Select, |
| 13 | SelectGroup, | 13 | SelectGroup, |
| ... | @@ -37,6 +37,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -37,6 +37,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 37 | previousWorktreeLabel, | 37 | previousWorktreeLabel, |
| 38 | onUsePreviousWorktree, | 38 | onUsePreviousWorktree, |
| 39 | }: BranchToolbarEnvModeSelectorProps) { | 39 | }: BranchToolbarEnvModeSelectorProps) { |
| 40 | const composerFloatingLayerProps = useComposerMenuProps(); | ||
| 40 | const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); | 41 | const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); |
| 41 | const envModeItems = useMemo( | 42 | const envModeItems = useMemo( |
| 42 | () => [ | 43 | () => [ |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+2-1| ... | @@ -4,7 +4,7 @@ import { memo, useMemo } from "react"; | ... | @@ -4,7 +4,7 @@ import { memo, useMemo } from "react"; |
| 4 | 4 | ||
| 5 | import type { EnvironmentOption } from "./BranchToolbar.logic"; | 5 | import type { EnvironmentOption } from "./BranchToolbar.logic"; |
| 6 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 6 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 7 | import { composerFloatingLayerProps } from "./chat/composerEventScope"; | 7 | import { useComposerMenuProps } from "./chat/composerEventScope"; |
| 8 | import { | 8 | import { |
| 9 | Select, | 9 | Select, |
| 10 | SelectGroup, | 10 | SelectGroup, |
| ... | @@ -34,6 +34,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir | ... | @@ -34,6 +34,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 34 | availableEnvironments, | 34 | availableEnvironments, |
| 35 | onEnvironmentChange, | 35 | onEnvironmentChange, |
| 36 | }: BranchToolbarEnvironmentSelectorProps) { | 36 | }: BranchToolbarEnvironmentSelectorProps) { |
| 37 | const composerFloatingLayerProps = useComposerMenuProps(); | ||
| 37 | const activeEnvironment = useMemo(() => { | 38 | const activeEnvironment = useMemo(() => { |
| 38 | return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; | 39 | return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; |
| 39 | }, [availableEnvironments, environmentId]); | 40 | }, [availableEnvironments, environmentId]); |
apps/web/src/components/chat/ChatComposer.tsx+2| ... | @@ -90,6 +90,7 @@ import { | ... | @@ -90,6 +90,7 @@ import { |
| 90 | } from "./composerMentionDrag"; | 90 | } from "./composerMentionDrag"; |
| 91 | import { | 91 | import { |
| 92 | composerFloatingLayerProps, | 92 | composerFloatingLayerProps, |
| 93 | useComposerMenuProps, | ||
| 93 | isInsideCollapsedComposerControls, | 94 | isInsideCollapsedComposerControls, |
| 94 | isInsideComposerFloatingLayer, | 95 | isInsideComposerFloatingLayer, |
| 95 | isInsideRestingComposerControlScope, | 96 | isInsideRestingComposerControlScope, |
| ... | @@ -1038,6 +1039,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop | ... | @@ -1038,6 +1039,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 1038 | onRuntimeModeChange: (mode: RuntimeMode) => void; | 1039 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 1039 | }) { | 1040 | }) { |
| 1040 | const size = props.size ?? "sm"; | 1041 | const size = props.size ?? "sm"; |
| 1042 | const composerFloatingLayerProps = useComposerMenuProps(); | ||
| 1041 | const [open, setOpen] = useComposerMenuState(props.hidden); | 1043 | const [open, setOpen] = useComposerMenuState(props.hidden); |
| 1042 | const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; | 1044 | const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; |
| 1043 | const RuntimeModeIcon = runtimeModeOption.icon; | 1045 | const RuntimeModeIcon = runtimeModeOption.icon; |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+2-1| ... | @@ -10,7 +10,7 @@ import { | ... | @@ -10,7 +10,7 @@ import { |
| 10 | MenuTrigger, | 10 | MenuTrigger, |
| 11 | } from "../ui/menu"; | 11 | } from "../ui/menu"; |
| 12 | import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; | 12 | import { ComposerControl, ComposerControlIcon } from "./ComposerControl"; |
| 13 | import { composerFloatingLayerProps } from "./composerEventScope"; | 13 | import { useComposerMenuProps } from "./composerEventScope"; |
| 14 | import { useComposerMenuState } from "./useComposerMenuState"; | 14 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 15 | 15 | ||
| 16 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { | 16 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { |
| ... | @@ -28,6 +28,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls | ... | @@ -28,6 +28,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 28 | onToggleInteractionMode: () => void; | 28 | onToggleInteractionMode: () => void; |
| 29 | onRuntimeModeChange: (mode: RuntimeMode) => void; | 29 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 30 | }) { | 30 | }) { |
| 31 | const composerFloatingLayerProps = useComposerMenuProps(); | ||
| 31 | const size = props.size ?? "sm"; | 32 | const size = props.size ?? "sm"; |
| 32 | const [open, setOpen] = useComposerMenuState(props.hidden); | 33 | const [open, setOpen] = useComposerMenuState(props.hidden); |
| 33 | 34 |
apps/web/src/components/chat/ProviderModelPicker.tsx+2-1| ... | @@ -24,7 +24,7 @@ import { | ... | @@ -24,7 +24,7 @@ import { |
| 24 | ComposerControlChevron, | 24 | ComposerControlChevron, |
| 25 | type ComposerControlSize, | 25 | type ComposerControlSize, |
| 26 | } from "./ComposerControl"; | 26 | } from "./ComposerControl"; |
| 27 | import { composerFloatingLayerProps } from "./composerEventScope"; | 27 | import { useComposerMenuProps } from "./composerEventScope"; |
| 28 | 28 | ||
| 29 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | 29 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 30 | /** | 30 | /** |
| ... | @@ -56,6 +56,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -56,6 +56,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 56 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; | 56 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; |
| 57 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; | 57 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| 58 | }) { | 58 | }) { |
| 59 | const composerFloatingLayerProps = useComposerMenuProps(); | ||
| 59 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); | 60 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); |
| 60 | const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; | 61 | const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; |
| 61 | const size = props.size ?? "sm"; | 62 | const size = props.size ?? "sm"; |
apps/web/src/components/chat/TraitsPicker.tsx+2-1| ... | @@ -38,7 +38,7 @@ import { | ... | @@ -38,7 +38,7 @@ import { |
| 38 | ComposerControlIcon, | 38 | ComposerControlIcon, |
| 39 | type ComposerControlSize, | 39 | type ComposerControlSize, |
| 40 | } from "./ComposerControl"; | 40 | } from "./ComposerControl"; |
| 41 | import { composerFloatingLayerProps } from "./composerEventScope"; | 41 | import { useComposerMenuProps } from "./composerEventScope"; |
| 42 | import { useComposerMenuState } from "./useComposerMenuState"; | 42 | import { useComposerMenuState } from "./useComposerMenuState"; |
| 43 | 43 | ||
| 44 | type ProviderOptions = ReadonlyArray<ProviderOptionSelection>; | 44 | type ProviderOptions = ReadonlyArray<ProviderOptionSelection>; |
| ... | @@ -557,6 +557,7 @@ export const TraitsPicker = memo(function TraitsPicker({ | ... | @@ -557,6 +557,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 557 | size?: ComposerControlSize; | 557 | size?: ComposerControlSize; |
| 558 | hidden?: boolean; | 558 | hidden?: boolean; |
| 559 | }) { | 559 | }) { |
| 560 | const composerFloatingLayerProps = useComposerMenuProps(); | ||
| 560 | const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden); | 561 | const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden); |
| 561 | const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = | 562 | const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = |
| 562 | getTraitsSectionVisibility({ | 563 | getTraitsSectionVisibility({ |
apps/web/src/components/chat/composerEventScope.test.ts+40| ... | @@ -1,9 +1,13 @@ | ... | @@ -1,9 +1,13 @@ |
| 1 | import { act, createElement, useLayoutEffect } from "react"; | ||
| 2 | import { create } from "react-test-renderer"; | ||
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 4 | ||
| 5 | import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; | ||
| 3 | import { | 6 | import { |
| 4 | isInsideCollapsedComposerControls, | 7 | isInsideCollapsedComposerControls, |
| 5 | isInsideComposerFloatingLayer, | 8 | isInsideComposerFloatingLayer, |
| 6 | isInsideRestingComposerControlScope, | 9 | isInsideRestingComposerControlScope, |
| 10 | useComposerMenuProps, | ||
| 7 | } from "./composerEventScope"; | 11 | } from "./composerEventScope"; |
| 8 | 12 | ||
| 9 | class FakeElement { | 13 | class FakeElement { |
| ... | @@ -21,6 +25,42 @@ afterEach(() => { | ... | @@ -21,6 +25,42 @@ afterEach(() => { |
| 21 | vi.unstubAllGlobals(); | 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 | describe("composer event scopes", () => { | 64 | describe("composer event scopes", () => { |
| 25 | it("recognizes events from the portaled resting controls", () => { | 65 | it("recognizes events from the portaled resting controls", () => { |
| 26 | vi.stubGlobal("Element", FakeElement); | 66 | vi.stubGlobal("Element", FakeElement); |
apps/web/src/components/chat/composerEventScope.ts+20| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { useComposerHandleContext } from "../../composerHandleContext"; | ||
| 2 | |||
| 1 | const COMPOSER_FLOATING_LAYER_SELECTOR = [ | 3 | const COMPOSER_FLOATING_LAYER_SELECTOR = [ |
| 2 | '[data-composer-drawer-layer="true"]', | 4 | '[data-composer-drawer-layer="true"]', |
| 3 | '[data-chat-composer-floating-layer="true"]', | 5 | '[data-chat-composer-floating-layer="true"]', |
| ... | @@ -7,6 +9,24 @@ export const composerFloatingLayerProps = { | ... | @@ -7,6 +9,24 @@ export const composerFloatingLayerProps = { |
| 7 | "data-chat-composer-floating-layer": "true", | 9 | "data-chat-composer-floating-layer": "true", |
| 8 | } as const; | 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 | export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean { | 30 | export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean { |
| 11 | return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; | 31 | return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; |
| 12 | } | 32 | } |