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 {
5454} from "./ui/menu";
5555import { Separator } from "./ui/separator";
5656import { ComposerSurface } from "./chat/ComposerSurface";
57import { composerFloatingLayerProps } from "./chat/composerEventScope";
57import { useComposerMenuProps } from "./chat/composerEventScope";
5858import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
5959import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
6060import { cn } from "~/lib/utils";
......@@ -120,6 +120,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
120120 previousWorktreeLabel,
121121 onUsePreviousWorktree,
122122}: MobileRunContextSelectorProps) {
123 const composerFloatingLayerProps = useComposerMenuProps();
123124 const activeEnvironment = useMemo(
124125 () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
125126 [availableEnvironments, environmentId],
apps/web/src/components/BranchToolbarBranchSelector.tsx+2-1
......@@ -40,7 +40,7 @@ import { vcsEnvironment } from "../state/vcs";
4040import { cn } from "../lib/utils";
4141import { parsePullRequestReference } from "../pullRequestReference";
4242import { getSourceControlPresentation } from "../sourceControlPresentation";
43import { composerFloatingLayerProps } from "./chat/composerEventScope";
43import { useComposerMenuProps } from "./chat/composerEventScope";
4444import {
4545 deriveLocalBranchNameFromRemoteRef,
4646 resolveBranchTriggerLabel,
......@@ -113,6 +113,7 @@ export function BranchToolbarBranchSelector({
113113 onCheckoutPullRequestRequest,
114114 onComposerFocusRequest,
115115}: BranchToolbarBranchSelectorProps) {
116 const composerFloatingLayerProps = useComposerMenuProps();
116117 const startFromOriginSwitchId = useId();
117118 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
118119 const updateThreadMetadata = useAtomCommand(
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+2-1
......@@ -7,7 +7,7 @@ import {
77 resolveLockedWorkspaceLabel,
88 type EnvMode,
99} from "./BranchToolbar.logic";
10import { composerFloatingLayerProps } from "./chat/composerEventScope";
10import { useComposerMenuProps } from "./chat/composerEventScope";
1111import {
1212 Select,
1313 SelectGroup,
......@@ -37,6 +37,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
3737 previousWorktreeLabel,
3838 onUsePreviousWorktree,
3939}: BranchToolbarEnvModeSelectorProps) {
40 const composerFloatingLayerProps = useComposerMenuProps();
4041 const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);
4142 const envModeItems = useMemo(
4243 () => [
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+2-1
......@@ -4,7 +4,7 @@ import { memo, useMemo } from "react";
44
55import type { EnvironmentOption } from "./BranchToolbar.logic";
66import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
7import { composerFloatingLayerProps } from "./chat/composerEventScope";
7import { useComposerMenuProps } from "./chat/composerEventScope";
88import {
99 Select,
1010 SelectGroup,
......@@ -34,6 +34,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
3434 availableEnvironments,
3535 onEnvironmentChange,
3636}: BranchToolbarEnvironmentSelectorProps) {
37 const composerFloatingLayerProps = useComposerMenuProps();
3738 const activeEnvironment = useMemo(() => {
3839 return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null;
3940 }, [availableEnvironments, environmentId]);
apps/web/src/components/chat/ChatComposer.tsx+2
......@@ -90,6 +90,7 @@ import {
9090} from "./composerMentionDrag";
9191import {
9292 composerFloatingLayerProps,
93 useComposerMenuProps,
9394 isInsideCollapsedComposerControls,
9495 isInsideComposerFloatingLayer,
9596 isInsideRestingComposerControlScope,
......@@ -1038,6 +1039,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
10381039 onRuntimeModeChange: (mode: RuntimeMode) => void;
10391040}) {
10401041 const size = props.size ?? "sm";
1042 const composerFloatingLayerProps = useComposerMenuProps();
10411043 const [open, setOpen] = useComposerMenuState(props.hidden);
10421044 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
10431045 const RuntimeModeIcon = runtimeModeOption.icon;
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+2-1
......@@ -10,7 +10,7 @@ import {
1010 MenuTrigger,
1111} from "../ui/menu";
1212import { ComposerControl, ComposerControlIcon } from "./ComposerControl";
13import { composerFloatingLayerProps } from "./composerEventScope";
13import { useComposerMenuProps } from "./composerEventScope";
1414import { useComposerMenuState } from "./useComposerMenuState";
1515
1616export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
......@@ -28,6 +28,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
2828 onToggleInteractionMode: () => void;
2929 onRuntimeModeChange: (mode: RuntimeMode) => void;
3030}) {
31 const composerFloatingLayerProps = useComposerMenuProps();
3132 const size = props.size ?? "sm";
3233 const [open, setOpen] = useComposerMenuState(props.hidden);
3334
apps/web/src/components/chat/ProviderModelPicker.tsx+2-1
......@@ -24,7 +24,7 @@ import {
2424 ComposerControlChevron,
2525 type ComposerControlSize,
2626} from "./ComposerControl";
27import { composerFloatingLayerProps } from "./composerEventScope";
27import { useComposerMenuProps } from "./composerEventScope";
2828
2929export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
3030 /**
......@@ -56,6 +56,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5656 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
5757 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
5858}) {
59 const composerFloatingLayerProps = useComposerMenuProps();
5960 const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);
6061 const isMenuOpen = props.open ?? uncontrolledIsMenuOpen;
6162 const size = props.size ?? "sm";
apps/web/src/components/chat/TraitsPicker.tsx+2-1
......@@ -38,7 +38,7 @@ import {
3838 ComposerControlIcon,
3939 type ComposerControlSize,
4040} from "./ComposerControl";
41import { composerFloatingLayerProps } from "./composerEventScope";
41import { useComposerMenuProps } from "./composerEventScope";
4242import { useComposerMenuState } from "./useComposerMenuState";
4343
4444type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
......@@ -557,6 +557,7 @@ export const TraitsPicker = memo(function TraitsPicker({
557557 size?: ComposerControlSize;
558558 hidden?: boolean;
559559 }) {
560 const composerFloatingLayerProps = useComposerMenuProps();
560561 const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden);
561562 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
562563 getTraitsSectionVisibility({
apps/web/src/components/chat/composerEventScope.test.ts+40
......@@ -1,9 +1,13 @@
1import { act, createElement, useLayoutEffect } from "react";
2import { create } from "react-test-renderer";
13import { afterEach, describe, expect, it, vi } from "vite-plus/test";
24
5import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext";
36import {
47 isInsideCollapsedComposerControls,
58 isInsideComposerFloatingLayer,
69 isInsideRestingComposerControlScope,
10 useComposerMenuProps,
711} from "./composerEventScope";
812
913class FakeElement {
......@@ -21,6 +25,42 @@ afterEach(() => {
2125 vi.unstubAllGlobals();
2226});
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
2464describe("composer event scopes", () => {
2565 it("recognizes events from the portaled resting controls", () => {
2666 vi.stubGlobal("Element", FakeElement);
apps/web/src/components/chat/composerEventScope.ts+20
......@@ -1,3 +1,5 @@
1import { useComposerHandleContext } from "../../composerHandleContext";
2
13const COMPOSER_FLOATING_LAYER_SELECTOR = [
24 '[data-composer-drawer-layer="true"]',
35 '[data-chat-composer-floating-layer="true"]',
......@@ -7,6 +9,24 @@ export const composerFloatingLayerProps = {
79 "data-chat-composer-floating-layer": "true",
810} 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
1030export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean {
1131 return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null;
1232}