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