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 {...@@ -87,6 +87,7 @@ import {
87} from "../../promptStashStore";87} from "../../promptStashStore";
88import { ComposerStashBadge } from "./ComposerStashBadge";88import { ComposerStashBadge } from "./ComposerStashBadge";
89import { ComposerStashMenu } from "./ComposerStashMenu";89import { ComposerStashMenu } from "./ComposerStashMenu";
90import { useComposerMenuState } from "./useComposerMenuState";
90import {91import {
91 ComposerTasksBadge,92 ComposerTasksBadge,
92 ComposerTasksContent,93 ComposerTasksContent,
...@@ -911,10 +912,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -911,10 +912,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
911 interactionMode: ProviderInteractionMode;912 interactionMode: ProviderInteractionMode;
912 runtimeMode: RuntimeMode;913 runtimeMode: RuntimeMode;
913 size?: "sm" | "xs";914 size?: "sm" | "xs";
915 hidden?: boolean;
914 onToggleInteractionMode: () => void;916 onToggleInteractionMode: () => void;
915 onRuntimeModeChange: (mode: RuntimeMode) => void;917 onRuntimeModeChange: (mode: RuntimeMode) => void;
916}) {918}) {
917 const size = props.size ?? "sm";919 const size = props.size ?? "sm";
920 const [open, setOpen] = useComposerMenuState(props.hidden);
918 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];921 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
919 const RuntimeModeIcon = runtimeModeOption.icon;922 const RuntimeModeIcon = runtimeModeOption.icon;
920 const interactionModeTooltip =923 const interactionModeTooltip =
...@@ -972,6 +975,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -972,6 +975,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
972975
973 <Tooltip>976 <Tooltip>
974 <Select977 <Select
978 open={open}
979 onOpenChange={setOpen}
975 value={props.runtimeMode}980 value={props.runtimeMode}
976 onValueChange={(value) => props.onRuntimeModeChange(value!)}981 onValueChange={(value) => props.onRuntimeModeChange(value!)}
977 >982 >
...@@ -2090,10 +2095,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2090,10 +2095,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2090 isComposerOwned: true,2095 isComposerOwned: true,
2091 } satisfies Parameters<typeof renderProviderTraitsPicker>[0];2096 } satisfies Parameters<typeof renderProviderTraitsPicker>[0];
2092 const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput);2097 const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput);
2093 const restingProviderTraitsPicker = renderProviderTraitsPicker({
2094 ...providerTraitsPickerInput,
2095 size: "xs",
2096 });
2097 const {2098 const {
2098 controlsRef: restingComposerControlsRef,2099 controlsRef: restingComposerControlsRef,
2099 hiddenBlockCount: restingControlsHiddenBlockCount,2100 hiddenBlockCount: restingControlsHiddenBlockCount,
...@@ -3576,6 +3577,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3576,6 +3577,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3576 // to see or change the model without expanding the composer.3577 // to see or change the model without expanding the composer.
3577 const composerControlsInStrip = isComposerResting || isComposerCollapsedMobile;3578 const composerControlsInStrip = isComposerResting || isComposerCollapsedMobile;
3578 const composerControlsVisibleInStrip = composerControlsInStrip && restingControlsVisible;3579 const composerControlsVisibleInStrip = composerControlsInStrip && restingControlsVisible;
3580 const composerControlsHidden = composerControlsInStrip && !restingControlsVisible;
3581 if (composerControlsHidden && isComposerModelPickerOpen) {
3582 setIsComposerModelPickerOpen(false);
3583 }
3579 useLayoutEffect(() => {3584 useLayoutEffect(() => {
3580 onRestingControlsVisibilityChange(composerControlsVisibleInStrip);3585 onRestingControlsVisibilityChange(composerControlsVisibleInStrip);
3581 }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]);3586 }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]);
...@@ -3746,6 +3751,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3746,6 +3751,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37463751
3747 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;3752 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;
3748 const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact;3753 const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact;
3754 const restingProviderTraitsPicker = renderProviderTraitsPicker({
3755 ...providerTraitsPickerInput,
3756 size: "xs",
3757 hidden: composerControlsHidden || restingHiddenBlockCount > 1,
3758 });
3749 const restingBlockDefs = [3759 const restingBlockDefs = [
3750 ...(providerTraitsPicker3760 ...(providerTraitsPicker
3751 ? [3761 ? [
...@@ -3768,6 +3778,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3768,6 +3778,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3768 interactionMode={interactionMode}3778 interactionMode={interactionMode}
3769 runtimeMode={runtimeMode}3779 runtimeMode={runtimeMode}
3770 size={composerControlsInStrip ? "xs" : "sm"}3780 size={composerControlsInStrip ? "xs" : "sm"}
3781 hidden={composerControlsHidden || restingHiddenBlockCount > 0}
3771 onToggleInteractionMode={toggleInteractionMode}3782 onToggleInteractionMode={toggleInteractionMode}
3772 onRuntimeModeChange={handleRuntimeModeChange}3783 onRuntimeModeChange={handleRuntimeModeChange}
3773 />3784 />
...@@ -3886,7 +3897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3886,7 +3897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3886 interactionMode={interactionMode}3897 interactionMode={interactionMode}
3887 runtimeMode={runtimeMode}3898 runtimeMode={runtimeMode}
3888 size="xs"3899 size="xs"
3889 hidden={hiddenRestingBlockIds.length === 0}3900 hidden={composerControlsHidden || hiddenRestingBlockIds.length === 0}
3890 showInteractionModeToggle={3901 showInteractionModeToggle={
3891 planModeUiEnabled && hiddenRestingBlockIds.includes("mode")3902 planModeUiEnabled && hiddenRestingBlockIds.includes("mode")
3892 }3903 }
...@@ -4436,6 +4447,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4436,6 +4447,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4436 // ------------------------------------------------------------------4447 // ------------------------------------------------------------------
4437 // Imperative handle4448 // Imperative handle
4438 // ------------------------------------------------------------------4449 // ------------------------------------------------------------------
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
4439 useImperativeHandle(4462 useImperativeHandle(
4440 composerRef,4463 composerRef,
4441 () => ({4464 () => ({
...@@ -4459,11 +4482,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4459,11 +4482,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4459 "cursor",4482 "cursor",
4460 { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor },4483 { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor },
4461 ),4484 ),
4462 openModelPicker: () => {4485 openModelPicker,
4463 setIsComposerModelPickerOpen(true);
4464 },
4465 toggleModelPicker: () => {4486 toggleModelPicker: () => {
4466 setIsComposerModelPickerOpen((open) => !open);4487 if (isComposerModelPickerOpen) {
4488 setIsComposerModelPickerOpen(false);
4489 } else {
4490 openModelPicker();
4491 }
4467 },4492 },
4468 compactContext: compactThreadContext,4493 compactContext: compactThreadContext,
4469 isModelPickerOpen: () => isComposerModelPickerOpen,4494 isModelPickerOpen: () => isComposerModelPickerOpen,
...@@ -4576,6 +4601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4576,6 +4601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4576 projectSelectionRequired,4601 projectSelectionRequired,
4577 applyPromptReplacement,4602 applyPromptReplacement,
4578 isComposerModelPickerOpen,4603 isComposerModelPickerOpen,
4604 openModelPicker,
4579 readComposerSnapshot,4605 readComposerSnapshot,
4580 selectedModel,4606 selectedModel,
4581 selectedModelOptionsForDispatch,4607 selectedModelOptionsForDispatch,
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+4-12
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";
2import { memo, type ReactNode, useState } from "react";2import { memo, type ReactNode } from "react";
3import { EllipsisIcon } from "lucide-react";3import { EllipsisIcon } from "lucide-react";
4import {4import {
5 Menu,5 Menu,
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11} from "../ui/menu";11} from "../ui/menu";
12import { ComposerControl, ComposerControlIcon } from "./ComposerControl";12import { ComposerControl, ComposerControlIcon } from "./ComposerControl";
13import { composerFloatingLayerProps } from "./composerEventScope";13import { composerFloatingLayerProps } from "./composerEventScope";
14import { useComposerMenuState } from "./useComposerMenuState";
1415
15export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {16export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
16 interactionMode: ProviderInteractionMode;17 interactionMode: ProviderInteractionMode;
...@@ -28,19 +29,10 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls...@@ -28,19 +29,10 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
28 onRuntimeModeChange: (mode: RuntimeMode) => void;29 onRuntimeModeChange: (mode: RuntimeMode) => void;
29}) {30}) {
30 const size = props.size ?? "sm";31 const size = props.size ?? "sm";
31 const [open, setOpen] = useState(false);32 const [open, setOpen] = useComposerMenuState(props.hidden);
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 }
4133
42 return (34 return (
43 <Menu open={open && !hidden} onOpenChange={setOpen}>35 <Menu open={open} onOpenChange={setOpen}>
44 <MenuTrigger36 <MenuTrigger
45 render={37 render={
46 <ComposerControl38 <ComposerControl
apps/web/src/components/chat/TraitsPicker.tsx+5-2
...@@ -15,7 +15,7 @@ import {...@@ -15,7 +15,7 @@ import {
15 isClaudeUltrathinkPrompt,15 isClaudeUltrathinkPrompt,
16 normalizeModelSlug,16 normalizeModelSlug,
17} from "@t3tools/shared/model";17} from "@t3tools/shared/model";
18import { memo, useCallback, useState } from "react";18import { memo, useCallback } from "react";
19import type { VariantProps } from "class-variance-authority";19import type { VariantProps } from "class-variance-authority";
20import { ZapIcon } from "lucide-react";20import { ZapIcon } from "lucide-react";
21import { buttonVariants } from "../ui/button";21import { buttonVariants } from "../ui/button";
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 type ComposerControlSize,39 type ComposerControlSize,
40} from "./ComposerControl";40} from "./ComposerControl";
41import { composerFloatingLayerProps } from "./composerEventScope";41import { composerFloatingLayerProps } from "./composerEventScope";
42import { useComposerMenuState } from "./useComposerMenuState";
4243
43type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;44type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
4445
...@@ -549,12 +550,14 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -549,12 +550,14 @@ export const TraitsPicker = memo(function TraitsPicker({
549 triggerClassName,550 triggerClassName,
550 isComposerOwned,551 isComposerOwned,
551 size = "sm",552 size = "sm",
553 hidden = false,
552 ...persistence554 ...persistence
553}: TraitsMenuContentProps &555}: TraitsMenuContentProps &
554 TraitsPersistence & {556 TraitsPersistence & {
555 size?: ComposerControlSize;557 size?: ComposerControlSize;
558 hidden?: boolean;
556 }) {559 }) {
557 const [isMenuOpen, setIsMenuOpen] = useState(false);560 const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden);
558 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =561 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
559 getTraitsSectionVisibility({562 getTraitsSectionVisibility({
560 provider,563 provider,
apps/web/src/components/chat/composerProviderState.tsx+3
...@@ -53,6 +53,7 @@ type TraitsRenderInput = {...@@ -53,6 +53,7 @@ type TraitsRenderInput = {
53 onPromptChange: (prompt: string) => void;53 onPromptChange: (prompt: string) => void;
54 planModeEnabled: boolean;54 planModeEnabled: boolean;
55 size?: ComposerControlSize;55 size?: ComposerControlSize;
56 hidden?: boolean;
56 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];57 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
57 triggerClassName?: string;58 triggerClassName?: string;
58 isComposerOwned?: boolean;59 isComposerOwned?: boolean;
...@@ -131,6 +132,7 @@ function renderTraitsControl(...@@ -131,6 +132,7 @@ function renderTraitsControl(
131 onPromptChange,132 onPromptChange,
132 planModeEnabled,133 planModeEnabled,
133 size,134 size,
135 hidden,
134 triggerVariant,136 triggerVariant,
135 triggerClassName,137 triggerClassName,
136 isComposerOwned,138 isComposerOwned,
...@@ -162,6 +164,7 @@ function renderTraitsControl(...@@ -162,6 +164,7 @@ function renderTraitsControl(
162 onPromptChange={onPromptChange}164 onPromptChange={onPromptChange}
163 planModeEnabled={planModeEnabled}165 planModeEnabled={planModeEnabled}
164 {...(size !== undefined ? { size } : {})}166 {...(size !== undefined ? { size } : {})}
167 {...(hidden !== undefined ? { hidden } : {})}
165 {...(triggerVariant !== undefined ? { triggerVariant } : {})}168 {...(triggerVariant !== undefined ? { triggerVariant } : {})}
166 {...(triggerClassName !== undefined ? { triggerClassName } : {})}169 {...(triggerClassName !== undefined ? { triggerClassName } : {})}
167 {...(isComposerOwned ? { isComposerOwned } : {})}170 {...(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}