authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 18:55:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 18:55:44-07:00
logf239b77df93077e40c27cc5c5909e94266571859
tree1300e922e421edd7024bd260e09874141c0d5ce5
parent3c3e05ccfe34ab7af273f4a4faa72909cafd7a21
signature Signed by PGP key B5690EEEBB952194

fix(web): close composer menus when their controls hide (#9541)


6 files changed, 149 insertions(+), 23 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+35-9
......@@ -87,6 +87,7 @@ import {
8787} from "../../promptStashStore";
8888import { ComposerStashBadge } from "./ComposerStashBadge";
8989import { ComposerStashMenu } from "./ComposerStashMenu";
90import { useComposerMenuState } from "./useComposerMenuState";
9091import {
9192 ComposerTasksBadge,
9293 ComposerTasksContent,
......@@ -911,10 +912,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
911912 interactionMode: ProviderInteractionMode;
912913 runtimeMode: RuntimeMode;
913914 size?: "sm" | "xs";
915 hidden?: boolean;
914916 onToggleInteractionMode: () => void;
915917 onRuntimeModeChange: (mode: RuntimeMode) => void;
916918}) {
917919 const size = props.size ?? "sm";
920 const [open, setOpen] = useComposerMenuState(props.hidden);
918921 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
919922 const RuntimeModeIcon = runtimeModeOption.icon;
920923 const interactionModeTooltip =
......@@ -972,6 +975,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
972975
973976 <Tooltip>
974977 <Select
978 open={open}
979 onOpenChange={setOpen}
975980 value={props.runtimeMode}
976981 onValueChange={(value) => props.onRuntimeModeChange(value!)}
977982 >
......@@ -2090,10 +2095,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20902095 isComposerOwned: true,
20912096 } satisfies Parameters<typeof renderProviderTraitsPicker>[0];
20922097 const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput);
2093 const restingProviderTraitsPicker = renderProviderTraitsPicker({
2094 ...providerTraitsPickerInput,
2095 size: "xs",
2096 });
20972098 const {
20982099 controlsRef: restingComposerControlsRef,
20992100 hiddenBlockCount: restingControlsHiddenBlockCount,
......@@ -3576,6 +3577,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35763577 // to see or change the model without expanding the composer.
35773578 const composerControlsInStrip = isComposerResting || isComposerCollapsedMobile;
35783579 const composerControlsVisibleInStrip = composerControlsInStrip && restingControlsVisible;
3580 const composerControlsHidden = composerControlsInStrip && !restingControlsVisible;
3581 if (composerControlsHidden && isComposerModelPickerOpen) {
3582 setIsComposerModelPickerOpen(false);
3583 }
35793584 useLayoutEffect(() => {
35803585 onRestingControlsVisibilityChange(composerControlsVisibleInStrip);
35813586 }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]);
......@@ -3746,6 +3751,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37463751
37473752 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;
37483753 const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact;
3754 const restingProviderTraitsPicker = renderProviderTraitsPicker({
3755 ...providerTraitsPickerInput,
3756 size: "xs",
3757 hidden: composerControlsHidden || restingHiddenBlockCount > 1,
3758 });
37493759 const restingBlockDefs = [
37503760 ...(providerTraitsPicker
37513761 ? [
......@@ -3768,6 +3778,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37683778 interactionMode={interactionMode}
37693779 runtimeMode={runtimeMode}
37703780 size={composerControlsInStrip ? "xs" : "sm"}
3781 hidden={composerControlsHidden || restingHiddenBlockCount > 0}
37713782 onToggleInteractionMode={toggleInteractionMode}
37723783 onRuntimeModeChange={handleRuntimeModeChange}
37733784 />
......@@ -3886,7 +3897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38863897 interactionMode={interactionMode}
38873898 runtimeMode={runtimeMode}
38883899 size="xs"
3889 hidden={hiddenRestingBlockIds.length === 0}
3900 hidden={composerControlsHidden || hiddenRestingBlockIds.length === 0}
38903901 showInteractionModeToggle={
38913902 planModeUiEnabled && hiddenRestingBlockIds.includes("mode")
38923903 }
......@@ -4436,6 +4447,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44364447 // ------------------------------------------------------------------
44374448 // Imperative handle
44384449 // ------------------------------------------------------------------
4450 const openModelPicker = useCallback(() => {
4451 if (composerControlsHidden) {
4452 if (composerBlurFrameRef.current !== null) {
4453 window.cancelAnimationFrame(composerBlurFrameRef.current);
4454 composerBlurFrameRef.current = null;
4455 }
4456 setIsComposerScrollCollapsed(false);
4457 setIsComposerFocused(true);
4458 }
4459 setIsComposerModelPickerOpen(true);
4460 }, [composerControlsHidden]);
4461
44394462 useImperativeHandle(
44404463 composerRef,
44414464 () => ({
......@@ -4459,11 +4482,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44594482 "cursor",
44604483 { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor },
44614484 ),
4462 openModelPicker: () => {
4463 setIsComposerModelPickerOpen(true);
4464 },
4485 openModelPicker,
44654486 toggleModelPicker: () => {
4466 setIsComposerModelPickerOpen((open) => !open);
4487 if (isComposerModelPickerOpen) {
4488 setIsComposerModelPickerOpen(false);
4489 } else {
4490 openModelPicker();
4491 }
44674492 },
44684493 compactContext: compactThreadContext,
44694494 isModelPickerOpen: () => isComposerModelPickerOpen,
......@@ -4576,6 +4601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
45764601 projectSelectionRequired,
45774602 applyPromptReplacement,
45784603 isComposerModelPickerOpen,
4604 openModelPicker,
45794605 readComposerSnapshot,
45804606 selectedModel,
45814607 selectedModelOptionsForDispatch,
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+4-12
......@@ -1,5 +1,5 @@
11import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";
2import { memo, type ReactNode, useState } from "react";
2import { memo, type ReactNode } from "react";
33import { EllipsisIcon } from "lucide-react";
44import {
55 Menu,
......@@ -11,6 +11,7 @@ import {
1111} from "../ui/menu";
1212import { ComposerControl, ComposerControlIcon } from "./ComposerControl";
1313import { composerFloatingLayerProps } from "./composerEventScope";
14import { useComposerMenuState } from "./useComposerMenuState";
1415
1516export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
1617 interactionMode: ProviderInteractionMode;
......@@ -28,19 +29,10 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
2829 onRuntimeModeChange: (mode: RuntimeMode) => void;
2930}) {
3031 const size = props.size ?? "sm";
31 const [open, setOpen] = useState(false);
32 const hidden = props.hidden ?? false;
33 // Base UI does not report a close it did not initiate, so clear the state
34 // when the trigger hides or the menu would reopen by itself when the
35 // trigger returns.
36 const [wasHidden, setWasHidden] = useState(hidden);
37 if (hidden !== wasHidden) {
38 setWasHidden(hidden);
39 if (hidden) setOpen(false);
40 }
32 const [open, setOpen] = useComposerMenuState(props.hidden);
4133
4234 return (
43 <Menu open={open && !hidden} onOpenChange={setOpen}>
35 <Menu open={open} onOpenChange={setOpen}>
4436 <MenuTrigger
4537 render={
4638 <ComposerControl
apps/web/src/components/chat/TraitsPicker.tsx+5-2
......@@ -15,7 +15,7 @@ import {
1515 isClaudeUltrathinkPrompt,
1616 normalizeModelSlug,
1717} from "@t3tools/shared/model";
18import { memo, useCallback, useState } from "react";
18import { memo, useCallback } from "react";
1919import type { VariantProps } from "class-variance-authority";
2020import { ZapIcon } from "lucide-react";
2121import { buttonVariants } from "../ui/button";
......@@ -39,6 +39,7 @@ import {
3939 type ComposerControlSize,
4040} from "./ComposerControl";
4141import { composerFloatingLayerProps } from "./composerEventScope";
42import { useComposerMenuState } from "./useComposerMenuState";
4243
4344type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
4445
......@@ -549,12 +550,14 @@ export const TraitsPicker = memo(function TraitsPicker({
549550 triggerClassName,
550551 isComposerOwned,
551552 size = "sm",
553 hidden = false,
552554 ...persistence
553555}: TraitsMenuContentProps &
554556 TraitsPersistence & {
555557 size?: ComposerControlSize;
558 hidden?: boolean;
556559 }) {
557 const [isMenuOpen, setIsMenuOpen] = useState(false);
560 const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden);
558561 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
559562 getTraitsSectionVisibility({
560563 provider,
apps/web/src/components/chat/composerProviderState.tsx+3
......@@ -53,6 +53,7 @@ type TraitsRenderInput = {
5353 onPromptChange: (prompt: string) => void;
5454 planModeEnabled: boolean;
5555 size?: ComposerControlSize;
56 hidden?: boolean;
5657 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
5758 triggerClassName?: string;
5859 isComposerOwned?: boolean;
......@@ -131,6 +132,7 @@ function renderTraitsControl(
131132 onPromptChange,
132133 planModeEnabled,
133134 size,
135 hidden,
134136 triggerVariant,
135137 triggerClassName,
136138 isComposerOwned,
......@@ -162,6 +164,7 @@ function renderTraitsControl(
162164 onPromptChange={onPromptChange}
163165 planModeEnabled={planModeEnabled}
164166 {...(size !== undefined ? { size } : {})}
167 {...(hidden !== undefined ? { hidden } : {})}
165168 {...(triggerVariant !== undefined ? { triggerVariant } : {})}
166169 {...(triggerClassName !== undefined ? { triggerClassName } : {})}
167170 {...(isComposerOwned ? { isComposerOwned } : {})}
apps/web/src/components/chat/useComposerMenuState.test.tsx created+90
......@@ -0,0 +1,90 @@
1import { act, StrictMode, useLayoutEffect } from "react";
2import { createRoot, type Root } from "react-dom/client";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { useComposerMenuState } from "./useComposerMenuState";
6
7let root: Root;
8let setOpen: ReturnType<typeof useComposerMenuState>[1];
9let committedOpen: boolean[];
10let closePopup = vi.fn<() => void>();
11
12function MenuStateProbe({ hidden }: { hidden: boolean }) {
13 const [open, updateOpen] = useComposerMenuState(hidden);
14 useLayoutEffect(() => {
15 setOpen = updateOpen;
16 committedOpen.push(open);
17 if (open) return closePopup;
18 }, [open, updateOpen]);
19 return null;
20}
21
22async function renderMenu(hidden: boolean) {
23 await act(() => {
24 root.render(
25 <StrictMode>
26 <MenuStateProbe hidden={hidden} />
27 </StrictMode>,
28 );
29 });
30}
31
32beforeEach(() => {
33 // The probe renders no host nodes, but ReactDOM still needs an event target.
34 const document = {
35 nodeType: 9,
36 addEventListener() {},
37 removeEventListener() {},
38 };
39 const container = {
40 nodeType: 1,
41 tagName: "DIV",
42 namespaceURI: "http://www.w3.org/1999/xhtml",
43 ownerDocument: document,
44 addEventListener() {},
45 removeEventListener() {},
46 };
47 vi.stubGlobal("document", document);
48 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
49 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
50 committedOpen = [];
51 closePopup = vi.fn();
52 root = createRoot(container as unknown as HTMLElement);
53});
54
55afterEach(async () => {
56 await act(() => root.unmount());
57 vi.unstubAllGlobals();
58});
59
60describe("useComposerMenuState", () => {
61 it("closes an open popup when its trigger hides and does not reopen when it returns", async () => {
62 await renderMenu(false);
63 await act(() => setOpen(true));
64 expect(committedOpen.at(-1)).toBe(true);
65
66 await renderMenu(true);
67 expect(committedOpen.at(-1)).toBe(false);
68 expect(closePopup).toHaveBeenCalledTimes(1);
69
70 const afterClose = committedOpen.length;
71 await renderMenu(false);
72 expect(committedOpen).toHaveLength(afterClose);
73 expect(committedOpen.at(-1)).toBe(false);
74
75 await act(() => setOpen(true));
76 expect(committedOpen.at(-1)).toBe(true);
77 await act(() => setOpen(false));
78 expect(committedOpen.at(-1)).toBe(false);
79 expect(closePopup).toHaveBeenCalledTimes(2);
80 });
81
82 it("does not commit an open popup requested while its trigger is hidden", async () => {
83 await renderMenu(true);
84 await act(() => setOpen(true));
85 await renderMenu(false);
86
87 expect(committedOpen.every((open) => !open)).toBe(true);
88 expect(closePopup).not.toHaveBeenCalled();
89 });
90});
apps/web/src/components/chat/useComposerMenuState.ts created+12
......@@ -0,0 +1,12 @@
1import { useState } from "react";
2
3/** Clear portaled menus when their measured, still-mounted triggers hide. */
4export function useComposerMenuState(hidden = false) {
5 const [open, setOpen] = useState(false);
6 // Base UI does not notify us when we close it by changing its open prop.
7 // Reset the stored state too, so revealing the trigger cannot reopen it.
8 if (hidden && open) {
9 setOpen(false);
10 }
11 return [open && !hidden, setOpen] as const;
12}