authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-15 16:02:38+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 18:32:38+05:30
log3c4c9a125bf6e3dd7207fff23b4bf3d449e4df36
tree55ed6daf6f13fc58385960e89f88b748160d1ad0
parent50ff4c371eab927a9650c114975241999f4cd7b1
signature Signed by PGP key B5690EEEBB952194

fix(web): restore composer focus after closing option menus (#11884)


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";
55import { Separator } from "./ui/separator";55import { Separator } from "./ui/separator";
56import { ComposerSurface } from "./chat/ComposerSurface";56import { ComposerSurface } from "./chat/ComposerSurface";
57import { composerFloatingLayerProps } from "./chat/composerEventScope";57import { useComposerMenuProps } from "./chat/composerEventScope";
58import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";58import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
59import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";59import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
60import { cn } from "~/lib/utils";60import { 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";
40import { cn } from "../lib/utils";40import { cn } from "../lib/utils";
41import { parsePullRequestReference } from "../pullRequestReference";41import { parsePullRequestReference } from "../pullRequestReference";
42import { getSourceControlPresentation } from "../sourceControlPresentation";42import { getSourceControlPresentation } from "../sourceControlPresentation";
43import { composerFloatingLayerProps } from "./chat/composerEventScope";43import { useComposerMenuProps } from "./chat/composerEventScope";
44import {44import {
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";
10import { composerFloatingLayerProps } from "./chat/composerEventScope";10import { useComposerMenuProps } from "./chat/composerEventScope";
11import {11import {
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";
44
5import type { EnvironmentOption } from "./BranchToolbar.logic";5import type { EnvironmentOption } from "./BranchToolbar.logic";
6import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";6import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
7import { composerFloatingLayerProps } from "./chat/composerEventScope";7import { useComposerMenuProps } from "./chat/composerEventScope";
8import {8import {
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";
91import {91import {
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";
12import { ComposerControl, ComposerControlIcon } from "./ComposerControl";12import { ComposerControl, ComposerControlIcon } from "./ComposerControl";
13import { composerFloatingLayerProps } from "./composerEventScope";13import { useComposerMenuProps } from "./composerEventScope";
14import { useComposerMenuState } from "./useComposerMenuState";14import { useComposerMenuState } from "./useComposerMenuState";
1515
16export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {16export 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);
3334
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";
27import { composerFloatingLayerProps } from "./composerEventScope";27import { useComposerMenuProps } from "./composerEventScope";
2828
29export const ProviderModelPicker = memo(function ProviderModelPicker(props: {29export 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";
41import { composerFloatingLayerProps } from "./composerEventScope";41import { useComposerMenuProps } from "./composerEventScope";
42import { useComposerMenuState } from "./useComposerMenuState";42import { useComposerMenuState } from "./useComposerMenuState";
4343
44type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;44type 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 @@
1import { act, createElement, useLayoutEffect } from "react";
2import { create } from "react-test-renderer";
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
24
5import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext";
3import {6import {
4 isInsideCollapsedComposerControls,7 isInsideCollapsedComposerControls,
5 isInsideComposerFloatingLayer,8 isInsideComposerFloatingLayer,
6 isInsideRestingComposerControlScope,9 isInsideRestingComposerControlScope,
10 useComposerMenuProps,
7} from "./composerEventScope";11} from "./composerEventScope";
812
9class FakeElement {13class FakeElement {
...@@ -21,6 +25,42 @@ afterEach(() => {...@@ -21,6 +25,42 @@ afterEach(() => {
21 vi.unstubAllGlobals();25 vi.unstubAllGlobals();
22});26});
2327
28describe("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
24describe("composer event scopes", () => {64describe("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 @@
1import { useComposerHandleContext } from "../../composerHandleContext";
2
1const COMPOSER_FLOATING_LAYER_SELECTOR = [3const 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;
911
12export 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
10export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean {30export 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}