| author | |
| committer | |
| log | f239b77df93077e40c27cc5c5909e94266571859 |
| tree | 1300e922e421edd7024bd260e09874141c0d5ce5 |
| parent | 3c3e05ccfe34ab7af273f4a4faa72909cafd7a21 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 88 | import { ComposerStashBadge } from "./ComposerStashBadge"; | 88 | import { ComposerStashBadge } from "./ComposerStashBadge"; |
| 89 | import { ComposerStashMenu } from "./ComposerStashMenu"; | 89 | import { ComposerStashMenu } from "./ComposerStashMenu"; |
| 90 | import { useComposerMenuState } from "./useComposerMenuState"; | ||
| 90 | import { | 91 | import { |
| 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 |
| 972 | 975 | ||
| 973 | <Tooltip> | 976 | <Tooltip> |
| 974 | <Select | 977 | <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) |
| 3746 | 3751 | ||
| 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 | ...(providerTraitsPicker | 3760 | ...(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 handle | 4448 | // 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 @@ |
| 1 | import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; | 1 | import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; |
| 2 | import { memo, type ReactNode, useState } from "react"; | 2 | import { memo, type ReactNode } from "react"; |
| 3 | import { EllipsisIcon } from "lucide-react"; | 3 | import { EllipsisIcon } from "lucide-react"; |
| 4 | import { | 4 | import { |
| 5 | Menu, | 5 | Menu, |
| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 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 { composerFloatingLayerProps } from "./composerEventScope"; |
| 14 | import { useComposerMenuState } from "./useComposerMenuState"; | ||
| 14 | 15 | ||
| 15 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { | 16 | export 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 | } | ||
| 41 | 33 | ||
| 42 | return ( | 34 | return ( |
| 43 | <Menu open={open && !hidden} onOpenChange={setOpen}> | 35 | <Menu open={open} onOpenChange={setOpen}> |
| 44 | <MenuTrigger | 36 | <MenuTrigger |
| 45 | render={ | 37 | render={ |
| 46 | <ComposerControl | 38 | <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"; |
| 18 | import { memo, useCallback, useState } from "react"; | 18 | import { memo, useCallback } from "react"; |
| 19 | import type { VariantProps } from "class-variance-authority"; | 19 | import type { VariantProps } from "class-variance-authority"; |
| 20 | import { ZapIcon } from "lucide-react"; | 20 | import { ZapIcon } from "lucide-react"; |
| 21 | import { buttonVariants } from "../ui/button"; | 21 | import { 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"; |
| 41 | import { composerFloatingLayerProps } from "./composerEventScope"; | 41 | import { composerFloatingLayerProps } from "./composerEventScope"; |
| 42 | import { useComposerMenuState } from "./useComposerMenuState"; | ||
| 42 | 43 | ||
| 43 | type ProviderOptions = ReadonlyArray<ProviderOptionSelection>; | 44 | type ProviderOptions = ReadonlyArray<ProviderOptionSelection>; |
| 44 | 45 | ||
| ... | @@ -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 | ...persistence | 554 | ...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 @@ | ||
| 1 | import { act, StrictMode, useLayoutEffect } from "react"; | ||
| 2 | import { createRoot, type Root } from "react-dom/client"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { useComposerMenuState } from "./useComposerMenuState"; | ||
| 6 | |||
| 7 | let root: Root; | ||
| 8 | let setOpen: ReturnType<typeof useComposerMenuState>[1]; | ||
| 9 | let committedOpen: boolean[]; | ||
| 10 | let closePopup = vi.fn<() => void>(); | ||
| 11 | |||
| 12 | function 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 | |||
| 22 | async function renderMenu(hidden: boolean) { | ||
| 23 | await act(() => { | ||
| 24 | root.render( | ||
| 25 | <StrictMode> | ||
| 26 | <MenuStateProbe hidden={hidden} /> | ||
| 27 | </StrictMode>, | ||
| 28 | ); | ||
| 29 | }); | ||
| 30 | } | ||
| 31 | |||
| 32 | beforeEach(() => { | ||
| 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 | |||
| 55 | afterEach(async () => { | ||
| 56 | await act(() => root.unmount()); | ||
| 57 | vi.unstubAllGlobals(); | ||
| 58 | }); | ||
| 59 | |||
| 60 | describe("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 @@ | ||
| 1 | import { useState } from "react"; | ||
| 2 | |||
| 3 | /** Clear portaled menus when their measured, still-mounted triggers hide. */ | ||
| 4 | export 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 | } | ||