authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-21 01:41:28-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 01:41:28-03:00
log6cc7f7006e24283072cc3e0b3ed8ced80c6d71a5
treed99a882828c268dc59a1a303af49e8e212ad8398
parenta6cb1dd2054c96c5be41980e63594ea51554a552
signature Signed by PGP key B5690EEEBB952194

fix(web): keep composer controls visible while they fit (#12837)


6 files changed, 327 insertions(+), 169 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+106-105
...@@ -53,7 +53,6 @@ import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop";...@@ -53,7 +53,6 @@ import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop";
53import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";53import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
54import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";54import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
55import {55import {
56 Fragment,
57 memo,56 memo,
58 type ComponentProps,57 type ComponentProps,
59 type ReactNode,58 type ReactNode,
...@@ -187,8 +186,6 @@ import {...@@ -187,8 +186,6 @@ import {
187import { useComposerPathSearch } from "../../lib/composerPathSearchState";186import { useComposerPathSearch } from "../../lib/composerPathSearchState";
188import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";187import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
189import {188import {
190 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
191 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
192 getRestingComposerImagePreviewCounts,189 getRestingComposerImagePreviewCounts,
193 resolveRestingComposerControlsLayout,190 resolveRestingComposerControlsLayout,
194 shouldAnimateComposerRestingTransition,191 shouldAnimateComposerRestingTransition,
...@@ -197,7 +194,6 @@ import {...@@ -197,7 +194,6 @@ import {
197 shouldUseRestingComposerLayout,194 shouldUseRestingComposerLayout,
198} from "../composerFooterLayout";195} from "../composerFooterLayout";
199import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";196import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";
200import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
201import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";197import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
202import {198import {
203 ComposerContextActionsContext,199 ComposerContextActionsContext,
...@@ -994,22 +990,32 @@ const extendReplacementRangeForTrailingSpace = (...@@ -994,22 +990,32 @@ const extendReplacementRangeForTrailingSpace = (
994 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;990 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
995};991};
996992
997function useRestingComposerControlsLayout(host: HTMLDivElement | null) {993function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) {
994 const [controls, setControls] = useState<HTMLDivElement | null>(null);
998 const controlsRef = useRef<HTMLDivElement>(null);995 const controlsRef = useRef<HTMLDivElement>(null);
996 const attachControls = useCallback((element: HTMLDivElement | null) => {
997 controlsRef.current = element;
998 setControls(element);
999 }, []);
999 const hostRef = useRef(host);1000 const hostRef = useRef(host);
1000 hostRef.current = host;1001 hostRef.current = host;
1001 const [layout, setLayout] = useState({ hiddenCount: 0, visible: true });1002 const [layout, setLayout] = useState<{
1003 hiddenCount: number;
1004 iconOnlyCount?: number;
1005 visible: boolean;
1006 }>({ hiddenCount: 0, visible: true });
10021007
1003 const measure = useCallback(() => {1008 const measure = useCallback(() => {
1004 const currentHost = hostRef.current;1009 const currentHost = useControlsAsHost ? controls : hostRef.current;
1005 const controls = controlsRef.current;
1006 // The controls only mount while the composer rests, so the expanded
1007 // composer pays no layout reads here despite the every-render effect.
1008 if (currentHost === null || !controls) return;1010 if (currentHost === null || !controls) return;
10091011
1010 const measurement = measureRestingComposerControls(controls);1012 const measurement = measureRestingComposerControls(controls);
1011 if (!measurement) return;1013 if (!measurement) return;
1012 const hostWidth = currentHost.clientWidth;1014 const style = getComputedStyle(currentHost);
1015 const hostWidth =
1016 currentHost.clientWidth -
1017 (Number.parseFloat(style.paddingInlineStart) || 0) -
1018 (Number.parseFloat(style.paddingInlineEnd) || 0);
10131019
1014 setLayout((current) => {1020 setLayout((current) => {
1015 const next = resolveRestingComposerControlsLayout({1021 const next = resolveRestingComposerControlsLayout({
...@@ -1017,25 +1023,48 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) {...@@ -1017,25 +1023,48 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) {
1017 hostWidth,1023 hostWidth,
1018 previous: current,1024 previous: current,
1019 });1025 });
1020 return next.hiddenCount === current.hiddenCount && next.visible === current.visible1026 return next.hiddenCount === current.hiddenCount &&
1027 next.iconOnlyCount === current.iconOnlyCount &&
1028 next.visible === current.visible
1021 ? current1029 ? current
1022 : next;1030 : next;
1023 });1031 });
1024 }, []);1032 }, [controls, useControlsAsHost]);
10251033
1026 useLayoutEffect(measure);1034 useLayoutEffect(measure, [measure, host]);
1027 useEffect(() => {1035 useEffect(() => {
1028 if (!host) return;1036 const currentHost = useControlsAsHost ? controls : host;
1037 if (!currentHost || !controls) return;
1029 const observer = new ResizeObserver(measure);1038 const observer = new ResizeObserver(measure);
1030 observer.observe(host);1039 const observeControls = () => {
1040 observer.disconnect();
1041 observer.observe(currentHost);
1042 observer.observe(controls);
1043 controls
1044 .querySelectorAll<HTMLElement>(
1045 "[data-resting-block], [data-composer-control-label], [data-chat-provider-model-picker-label]",
1046 )
1047 .forEach((element) => observer.observe(element));
1048 measure();
1049 };
1050 observeControls();
1051 const mutations = new MutationObserver(observeControls);
1052 mutations.observe(controls, { childList: true, subtree: true, characterData: true });
1031 document.fonts.addEventListener("loadingdone", measure);1053 document.fonts.addEventListener("loadingdone", measure);
1032 return () => {1054 return () => {
1033 observer.disconnect();1055 observer.disconnect();
1056 mutations.disconnect();
1034 document.fonts.removeEventListener("loadingdone", measure);1057 document.fonts.removeEventListener("loadingdone", measure);
1035 };1058 };
1036 }, [host, measure]);1059 }, [host, controls, useControlsAsHost, measure]);
10371060
1038 return { controlsRef, hiddenBlockCount: layout.hiddenCount, controlsVisible: layout.visible };1061 return {
1062 controlsRef,
1063 attachControls,
1064 hiddenBlockCount: layout.hiddenCount,
1065 iconOnlyBlockCount: layout.iconOnlyCount ?? 0,
1066 controlsVisible: layout.visible,
1067 };
1039}1068}
10401069
1041const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {1070const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
...@@ -1092,7 +1121,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -1092,7 +1121,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
1092 opticalSize={size === "xs" ? "default" : "large"}1121 opticalSize={size === "xs" ? "default" : "large"}
1093 />1122 />
1094 )}1123 )}
1095 <span className="sr-only sm:not-sr-only">1124 <span data-composer-control-label className="sr-only sm:not-sr-only">
1096 {props.interactionMode === "plan" ? "Plan" : "Build"}1125 {props.interactionMode === "plan" ? "Plan" : "Build"}
1097 </span>1126 </span>
1098 </TooltipTrigger>1127 </TooltipTrigger>
...@@ -1123,7 +1152,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -1123,7 +1152,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
1123 }1152 }
1124 >1153 >
1125 <ComposerControlIcon icon={RuntimeModeIcon} size={size} />1154 <ComposerControlIcon icon={RuntimeModeIcon} size={size} />
1126 <SelectValue>{runtimeModeOption.label}</SelectValue>1155 <SelectValue data-composer-control-label>{runtimeModeOption.label}</SelectValue>
1127 </TooltipTrigger>1156 </TooltipTrigger>
1128 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>1157 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
1129 {runtimeModeOptions.map((mode) => {1158 {runtimeModeOptions.map((mode) => {
...@@ -2104,8 +2133,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2104,8 +2133,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2104 key: 0,2133 key: 0,
2105 active: false,2134 active: false,
2106 });2135 });
2107 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
2108 usePanelAnimationSettings();
2109 const isComposerCollapsedMobile =2136 const isComposerCollapsedMobile =
2110 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt;2137 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt;
21112138
...@@ -2120,7 +2147,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2120,7 +2147,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2120 });2147 });
2121 const attachmentInputRef = useRef<HTMLInputElement>(null);2148 const attachmentInputRef = useRef<HTMLInputElement>(null);
2122 const composerFormRef = useRef<HTMLFormElement>(null);2149 const composerFormRef = useRef<HTMLFormElement>(null);
2123 const composerFooterControlsRef = useRef<HTMLDivElement>(null);
2124 const composerSurfaceRef = useRef<HTMLDivElement>(null);2150 const composerSurfaceRef = useRef<HTMLDivElement>(null);
2125 const providerInputRejectedRef = useRef(false);2151 const providerInputRejectedRef = useRef(false);
2126 const composerSelectLockRef = useRef(false);2152 const composerSelectLockRef = useRef(false);
...@@ -2628,9 +2654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2628,9 +2654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2628 const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput);2654 const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput);
2629 const {2655 const {
2630 controlsRef: restingComposerControlsRef,2656 controlsRef: restingComposerControlsRef,
2657 attachControls: attachRestingComposerControls,
2631 hiddenBlockCount: restingControlsHiddenBlockCount,2658 hiddenBlockCount: restingControlsHiddenBlockCount,
2659 iconOnlyBlockCount: restingControlsIconOnlyBlockCount,
2632 controlsVisible: restingControlsVisible,2660 controlsVisible: restingControlsVisible,
2633 } = useRestingComposerControlsLayout(restingControlsHost);2661 } = useRestingComposerControlsLayout(restingControlsHost);
2662 const expandedControlsLayout = useRestingComposerControlsLayout(null, true);
2634 const pendingPrimaryAction = useMemo(2663 const pendingPrimaryAction = useMemo(
2635 () =>2664 () =>
2636 activePendingProgress2665 activePendingProgress
...@@ -3177,22 +3206,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3177,22 +3206,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3177 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);3206 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
3178 setIsComposerFooterCompact(initialCompactness.footerCompact);3207 setIsComposerFooterCompact(initialCompactness.footerCompact);
3179 if (typeof ResizeObserver === "undefined") return;3208 if (typeof ResizeObserver === "undefined") return;
3180 const footerControls = composerFooterControlsRef.current;
3181 const stopFooterControlsFade = footerControls
3182 ? observeResponsiveBreakpointFade({
3183 target: footerControls,
3184 container: composerForm,
3185 active: panelAnimationsActive,
3186 durationMs: panelAnimationDurationMs,
3187 breakpoint: {
3188 value: composerFooterHasWideActions
3189 ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX
3190 : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
3191 unit: "px",
3192 },
3193 })
3194 : undefined;
3195
3196 const observer = new ResizeObserver(() => {3209 const observer = new ResizeObserver(() => {
3197 const nextCompactness = measureFooterCompactness();3210 const nextCompactness = measureFooterCompactness();
3198 setIsComposerPrimaryActionsCompact((previous) =>3211 setIsComposerPrimaryActionsCompact((previous) =>
...@@ -3208,7 +3221,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3208,7 +3221,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3208 observer.observe(composerForm);3221 observer.observe(composerForm);
3209 return () => {3222 return () => {
3210 observer.disconnect();3223 observer.disconnect();
3211 stopFooterControlsFade?.();
3212 };3224 };
3213 }, [3225 }, [
3214 activeThreadId,3226 activeThreadId,
...@@ -3216,8 +3228,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3216,8 +3228,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3216 composerFooterHasWideActions,3228 composerFooterHasWideActions,
3217 isComposerApprovalState,3229 isComposerApprovalState,
3218 isComposerCollapsedMobile,3230 isComposerCollapsedMobile,
3219 panelAnimationDurationMs,
3220 panelAnimationsActive,
3221 ]);3231 ]);
32223232
3223 // ------------------------------------------------------------------3233 // ------------------------------------------------------------------
...@@ -4926,11 +4936,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4926,11 +4936,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4926 setIsComposerScrollCollapsed,4936 setIsComposerScrollCollapsed,
4927 ]);4937 ]);
49284938
4929 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;4939 const restingHiddenBlockCount = composerControlsInStrip
4930 const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact;4940 ? restingControlsHiddenBlockCount
4941 : expandedControlsLayout.hiddenBlockCount;
4942 const iconOnlyBlockCount = composerControlsInStrip
4943 ? restingControlsIconOnlyBlockCount
4944 : expandedControlsLayout.iconOnlyBlockCount;
4931 const restingProviderTraitsPicker = renderProviderTraitsPicker({4945 const restingProviderTraitsPicker = renderProviderTraitsPicker({
4932 ...providerTraitsPickerInput,4946 ...providerTraitsPickerInput,
4933 size: "xs",4947 size: composerControlsInStrip ? "xs" : "sm",
4934 hidden: composerControlsHidden || restingHiddenBlockCount > 1,4948 hidden: composerControlsHidden || restingHiddenBlockCount > 1,
4935 });4949 });
4936 const restingBlockDefs = [4950 const restingBlockDefs = [
...@@ -4941,7 +4955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4941,7 +4955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4941 content: (4955 content: (
4942 <>4956 <>
4943 <ComposerControlSeparator size={composerControlsInStrip ? "xs" : "sm"} />4957 <ComposerControlSeparator size={composerControlsInStrip ? "xs" : "sm"} />
4944 {composerControlsInStrip ? restingProviderTraitsPicker : providerTraitsPicker}4958 {restingProviderTraitsPicker}
4945 </>4959 </>
4946 ),4960 ),
4947 },4961 },
...@@ -5048,7 +5062,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5048,7 +5062,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5048 triggerClassName={5062 triggerClassName={
5049 composerControlsInStrip5063 composerControlsInStrip
5050 ? "min-w-13 shrink text-xs! @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:w-0 @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:flex-none"5064 ? "min-w-13 shrink text-xs! @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:w-0 @max-[640px]/composer-surface:[&_[data-chat-provider-model-picker-label]]:flex-none"
5051 : "-ms-2.5"5065 : "-ms-2.5 min-w-13"
5052 }5066 }
5053 terminalOpen={terminalOpen}5067 terminalOpen={terminalOpen}
5054 open={isComposerModelPickerOpen}5068 open={isComposerModelPickerOpen}
...@@ -5075,65 +5089,52 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5075,65 +5089,52 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5075 onOpenProviderSetup={onOpenProviderSetup}5089 onOpenProviderSetup={onOpenProviderSetup}
5076 />5090 />
50775091
5078 {composerControlsCompact ? (5092 <>
5079 <CompactComposerControlsMenu5093 {restingBlockDefs.map((def, index) => {
5080 interactionMode={interactionMode}5094 const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount;
5081 runtimeMode={runtimeMode}5095 return (
5082 showInteractionModeToggle={planModeUiEnabled}
5083 traitsMenuContent={providerTraitsMenuContent}
5084 onToggleInteractionMode={toggleInteractionMode}
5085 onRuntimeModeChange={handleRuntimeModeChange}
5086 />
5087 ) : (
5088 <>
5089 {restingBlockDefs.map((def, index) => {
5090 if (!composerControlsInStrip) {
5091 return <Fragment key={def.id}>{def.content}</Fragment>;
5092 }
5093 const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount;
5094 return (
5095 <div
5096 key={def.id}
5097 data-resting-block={def.id}
5098 aria-hidden={hidden || undefined}
5099 inert={hidden || undefined}
5100 className={cn(
5101 "flex w-max min-w-max shrink-0 items-center gap-1",
5102 hidden && "pointer-events-none invisible absolute",
5103 )}
5104 >
5105 {def.content}
5106 </div>
5107 );
5108 })}
5109 {composerControlsInStrip ? (
5110 <div5096 <div
5111 data-resting-controls-overflow5097 key={def.id}
5112 aria-hidden={hiddenRestingBlockIds.length === 0 || undefined}5098 data-resting-block={def.id}
5113 inert={hiddenRestingBlockIds.length === 0 || undefined}5099 data-composer-block-icon-only={
5100 index >= restingBlockDefs.length - iconOnlyBlockCount ? "true" : "false"
5101 }
5102 aria-hidden={hidden || undefined}
5103 inert={hidden || undefined}
5114 className={cn(5104 className={cn(
5115 "min-w-0 shrink-0",5105 "flex w-max min-w-max shrink-0 items-center gap-1",
5116 hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute",5106 hidden && "pointer-events-none invisible absolute",
5107 index >= restingBlockDefs.length - iconOnlyBlockCount &&
5108 "[&_[data-composer-control-label]]:pointer-events-none [&_[data-composer-control-label]]:invisible [&_[data-composer-control-label]]:absolute [&_[data-composer-control-label]]:w-max [&_[data-composer-control-label]]:max-w-none [&_[data-composer-control-compact-icon]]:[visibility:inherit] [&_[data-composer-control-compact-icon]]:relative",
5117 )}5109 )}
5118 >5110 >
5119 <CompactComposerControlsMenu5111 {def.content}
5120 interactionMode={interactionMode}
5121 runtimeMode={runtimeMode}
5122 size="xs"
5123 hidden={composerControlsHidden || hiddenRestingBlockIds.length === 0}
5124 showInteractionModeToggle={
5125 planModeUiEnabled && hiddenRestingBlockIds.includes("mode")
5126 }
5127 traitsMenuContent={
5128 hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined
5129 }
5130 onToggleInteractionMode={toggleInteractionMode}
5131 onRuntimeModeChange={handleRuntimeModeChange}
5132 />
5133 </div>5112 </div>
5134 ) : null}5113 );
5135 </>5114 })}
5136 )}5115 <div
5116 data-resting-controls-overflow
5117 aria-hidden={hiddenRestingBlockIds.length === 0 || undefined}
5118 inert={hiddenRestingBlockIds.length === 0 || undefined}
5119 className={cn(
5120 "min-w-0 shrink-0",
5121 hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute",
5122 )}
5123 >
5124 <CompactComposerControlsMenu
5125 interactionMode={interactionMode}
5126 runtimeMode={runtimeMode}
5127 size={composerControlsInStrip ? "xs" : "sm"}
5128 hidden={composerControlsHidden || hiddenRestingBlockIds.length === 0}
5129 showInteractionModeToggle={planModeUiEnabled && hiddenRestingBlockIds.includes("mode")}
5130 traitsMenuContent={
5131 hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined
5132 }
5133 onToggleInteractionMode={toggleInteractionMode}
5134 onRuntimeModeChange={handleRuntimeModeChange}
5135 />
5136 </div>
5137 </>
5137 </>5138 </>
5138 );5139 );
5139 const showTasksTab =5140 const showTasksTab =
...@@ -6159,7 +6160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6159,7 +6160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6159 {composerControlsInStrip && restingControlsHost6160 {composerControlsInStrip && restingControlsHost
6160 ? createPortal(6161 ? createPortal(
6161 <div6162 <div
6162 ref={restingComposerControlsRef}6163 ref={attachRestingComposerControls}
6163 data-chat-composer-resting-controls="true"6164 data-chat-composer-resting-controls="true"
6164 aria-hidden={restingControlsVisible ? undefined : true}6165 aria-hidden={restingControlsVisible ? undefined : true}
6165 inert={restingControlsVisible ? undefined : true}6166 inert={restingControlsVisible ? undefined : true}
...@@ -6961,11 +6962,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6961,11 +6962,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6961 )}6962 )}
6962 >6963 >
6963 <div6964 <div
6964 ref={composerFooterControlsRef}6965 ref={expandedControlsLayout.attachControls}
6965 data-chat-composer-controls="left"6966 data-chat-composer-controls="left"
6966 data-chat-composer-footer-controls="true"6967 data-chat-composer-footer-controls="true"
6967 className={cn(6968 className={cn(
6968 "-m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",6969 "relative -m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
6969 isComposerResting && "hidden",6970 isComposerResting && "hidden",
6970 )}6971 )}
6971 >6972 >
apps/web/src/components/chat/TraitsPicker.tsx+63-34
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17} from "@t3tools/shared/model";17} from "@t3tools/shared/model";
18import { memo, useCallback } 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 { GaugeIcon, ZapIcon } from "lucide-react";
21import { buttonVariants } from "../ui/button";21import { buttonVariants } from "../ui/button";
22import {22import {
23 Menu,23 Menu,
...@@ -32,6 +32,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore";...@@ -32,6 +32,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
32import { getProviderModelCapabilities } from "../../providerModels";32import { getProviderModelCapabilities } from "../../providerModels";
33import { cn } from "~/lib/utils";33import { cn } from "~/lib/utils";
34import { Badge } from "../ui/badge";34import { Badge } from "../ui/badge";
35import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
35import {36import {
36 ComposerControl,37 ComposerControl,
37 ComposerControlChevron,38 ComposerControlChevron,
...@@ -589,6 +590,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -589,6 +590,7 @@ export const TraitsPicker = memo(function TraitsPicker({
589 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,590 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
590 ultrathinkPromptControlled,591 ultrathinkPromptControlled,
591 });592 });
593 const accessibleLabel = showFastModeIcon ? `${triggerLabel}, Fast mode on` : triggerLabel;
592 const fastModeIcon = showFastModeIcon ? (594 const fastModeIcon = showFastModeIcon ? (
593 <>595 <>
594 <ComposerControlIcon596 <ComposerControlIcon
...@@ -616,39 +618,66 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -616,39 +618,66 @@ export const TraitsPicker = memo(function TraitsPicker({
616 setIsMenuOpen(open);618 setIsMenuOpen(open);
617 }}619 }}
618 >620 >
619 <MenuTrigger621 <Tooltip>
620 render={622 <TooltipTrigger
621 <ComposerControl623 render={
622 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}624 <MenuTrigger
623 variant={triggerVariant ?? "ghost"}625 render={
624 size={size}626 <ComposerControl
625 className={cn(627 aria-label={accessibleLabel}
626 isCodexStyle628 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
627 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap sm:max-w-48"629 variant={triggerVariant ?? "ghost"}
628 : "shrink-0 whitespace-nowrap",630 size={size}
629 triggerClassName,631 className={cn(
630 )}632 isCodexStyle
631 />633 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap sm:max-w-48"
632 }634 : "shrink-0 whitespace-nowrap",
633 >635 triggerClassName,
634 {isCodexStyle ? (636 )}
635 // The label truncates itself; clipping the wrapper too would cut off637 />
636 // the chevron, whose negative end margin overhangs the wrapper edge.638 }
637 <span639 />
638 className={cn("flex min-w-0 w-full items-center", size === "xs" ? "gap-1" : "gap-1.5")}640 }
639 >641 >
640 {fastModeIcon}642 {isCodexStyle ? (
641 <span className="min-w-0 truncate">{triggerLabel}</span>643 // The label truncates itself; clipping the wrapper too would cut off
642 <ComposerControlChevron size={size} />644 // the chevron, whose negative end margin overhangs the wrapper edge.
643 </span>645 <span
644 ) : (646 className={cn(
645 <>647 "flex min-w-0 w-full items-center",
646 {fastModeIcon}648 size === "xs" ? "gap-1" : "gap-1.5",
647 <span>{triggerLabel}</span>649 )}
648 <ComposerControlChevron size={size} />650 >
649 </>651 {fastModeIcon ?? (
650 )}652 <span
651 </MenuTrigger>653 data-composer-control-compact-icon
654 className="pointer-events-none invisible absolute"
655 >
656 <ComposerControlIcon icon={GaugeIcon} size={size} />
657 </span>
658 )}
659 <span data-composer-control-label className="min-w-0 truncate">
660 {triggerLabel}
661 </span>
662 <ComposerControlChevron size={size} />
663 </span>
664 ) : (
665 <>
666 {fastModeIcon ?? (
667 <span
668 data-composer-control-compact-icon
669 className="pointer-events-none invisible absolute"
670 >
671 <ComposerControlIcon icon={GaugeIcon} size={size} />
672 </span>
673 )}
674 <span data-composer-control-label>{triggerLabel}</span>
675 <ComposerControlChevron size={size} />
676 </>
677 )}
678 </TooltipTrigger>
679 <TooltipPopup side="top">{accessibleLabel}</TooltipPopup>
680 </Tooltip>
652 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>681 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>
653 <TraitsMenuContent682 <TraitsMenuContent
654 provider={provider}683 provider={provider}
apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts+10-1
...@@ -20,7 +20,13 @@ function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth?...@@ -20,7 +20,13 @@ function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth?
20 }20 }
21 return null;21 return null;
22 },22 },
23 querySelectorAll: () => [{ getBoundingClientRect: () => ({ width: 140 }) }],23 querySelectorAll: () => [
24 {
25 dataset: {},
26 querySelectorAll: () => [],
27 getBoundingClientRect: () => ({ width: 140 }),
28 },
29 ],
24 };30 };
25 vi.stubGlobal("getComputedStyle", (element: unknown) => {31 vi.stubGlobal("getComputedStyle", (element: unknown) => {
26 if (element === label) return { flexGrow: input.flexGrow };32 if (element === label) return { flexGrow: input.flexGrow };
...@@ -40,6 +46,7 @@ describe("measureRestingComposerControls", () => {...@@ -40,6 +46,7 @@ describe("measureRestingComposerControls", () => {
40 expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196);46 expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196);
41 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({47 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
42 hiddenCount: 0,48 hiddenCount: 0,
49 iconOnlyCount: 0,
43 visible: true,50 visible: true,
44 });51 });
45 });52 });
...@@ -50,6 +57,7 @@ describe("measureRestingComposerControls", () => {...@@ -50,6 +57,7 @@ describe("measureRestingComposerControls", () => {
50 expect(measurement.naturalFixedWidth).toBe(192);57 expect(measurement.naturalFixedWidth).toBe(192);
51 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({58 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
52 hiddenCount: 1,59 hiddenCount: 1,
60 iconOnlyCount: 1,
53 visible: true,61 visible: true,
54 });62 });
55 });63 });
...@@ -60,6 +68,7 @@ describe("measureRestingComposerControls", () => {...@@ -60,6 +68,7 @@ describe("measureRestingComposerControls", () => {
60 expect(measurement.naturalFixedWidth).toBe(212);68 expect(measurement.naturalFixedWidth).toBe(212);
61 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({69 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
62 hiddenCount: 1,70 hiddenCount: 1,
71 iconOnlyCount: 1,
63 visible: true,72 visible: true,
64 });73 });
65 });74 });
apps/web/src/components/chat/restingComposerControlsMeasurement.ts+30-1
...@@ -42,6 +42,33 @@ function providerModelPickerMinimumWidth(picker: HTMLElement): number {...@@ -42,6 +42,33 @@ function providerModelPickerMinimumWidth(picker: HTMLElement): number {
42 return minWidth + elementInlineMarginWidth(picker);42 return minWidth + elementInlineMarginWidth(picker);
43}43}
4444
45function controlBlockWidths(block: HTMLElement): { natural: number; iconOnly: number } {
46 const compact = block.dataset.composerBlockIconOnly === "true";
47 let natural = elementOuterWidth(block);
48 let iconOnly = natural;
49 for (const label of block.querySelectorAll<HTMLElement>("[data-composer-control-label]")) {
50 // Labels remain mounted at natural width when icons replace them. Reading
51 // both variants from one tree avoids duplicate controls or write/read probes.
52 const labelStyle = getComputedStyle(label);
53 const inFlow = labelStyle.position !== "absolute";
54 // Phone widths already hide the build label with sr-only. Its clipping
55 // remains in effect even when compact styles replace its one-pixel width.
56 if (!inFlow && (!compact || labelStyle.clip !== "auto")) continue;
57 const labelWidth = label.scrollWidth;
58 const renderedWidth = inFlow ? label.getBoundingClientRect().width : 0;
59 const gap = Number.parseFloat(getComputedStyle(label.parentElement!).columnGap) || 0;
60 natural += labelWidth - renderedWidth + (inFlow ? 0 : gap);
61 iconOnly -= renderedWidth + (inFlow ? gap : 0);
62 }
63 for (const icon of block.querySelectorAll<HTMLElement>("[data-composer-control-compact-icon]")) {
64 const width = elementOuterWidth(icon);
65 const gap = Number.parseFloat(getComputedStyle(icon.parentElement!).columnGap) || 0;
66 natural -= compact ? width + gap : 0;
67 iconOnly += compact ? 0 : width + gap;
68 }
69 return { natural, iconOnly: Math.min(natural, iconOnly) };
70}
71
45/**72/**
46 * Read the natural widths of the resting composer controls from the DOM.73 * Read the natural widths of the resting composer controls from the DOM.
47 *74 *
...@@ -67,6 +94,7 @@ export function measureRestingComposerControls(...@@ -67,6 +94,7 @@ export function measureRestingComposerControls(
67 const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]");94 const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]");
68 const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0;95 const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0;
69 const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]"));96 const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]"));
97 const widths = blocks.map(controlBlockWidths);
70 return {98 return {
71 gap,99 gap,
72 naturalFixedWidth:100 naturalFixedWidth:
...@@ -75,7 +103,8 @@ export function measureRestingComposerControls(...@@ -75,7 +103,8 @@ export function measureRestingComposerControls(
75 minimumFixedWidth:103 minimumFixedWidth:
76 (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) +104 (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) +
77 separatorAndGapWidth,105 separatorAndGapWidth,
78 blockWidths: blocks.map(elementOuterWidth),106 blockWidths: widths.map((width) => width.natural),
107 iconOnlyBlockWidths: widths.map((width) => width.iconOnly),
79 overflowWidth: overflow ? elementOuterWidth(overflow) : 0,108 overflowWidth: overflow ? elementOuterWidth(overflow) : 0,
80 };109 };
81}110}
apps/web/src/components/composerFooterLayout.test.ts+76
...@@ -449,3 +449,79 @@ describe("resolveScrollToEndClearance", () => {...@@ -449,3 +449,79 @@ describe("resolveScrollToEndClearance", () => {
449 }449 }
450 });450 });
451});451});
452
453describe("progressive composer controls", () => {
454 const measurement = {
455 gap: 4,
456 naturalFixedWidth: 140,
457 minimumFixedWidth: 80,
458 blockWidths: [80, 140],
459 iconOnlyBlockWidths: [40, 60],
460 overflowWidth: 24,
461 };
462
463 it("keeps labels while they fit and removes trailing labels before controls", () => {
464 for (const [hostWidth, iconOnlyCount, hiddenCount] of [
465 [368, 0, 0],
466 [367, 1, 0],
467 [288, 1, 0],
468 [287, 2, 0],
469 [248, 2, 0],
470 [247, 2, 1],
471 [211, 2, 2],
472 ] as const) {
473 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth })).toEqual({
474 hiddenCount,
475 iconOnlyCount,
476 visible: true,
477 });
478 }
479 });
480
481 it("requires slack to restore labels and controls", () => {
482 for (const [hostWidth, previous, promoted] of [
483 [
484 368,
485 { hiddenCount: 0, iconOnlyCount: 1, visible: true },
486 { hiddenCount: 0, iconOnlyCount: 0, visible: true },
487 ],
488 [
489 288,
490 { hiddenCount: 0, iconOnlyCount: 2, visible: true },
491 { hiddenCount: 0, iconOnlyCount: 1, visible: true },
492 ],
493 [
494 248,
495 { hiddenCount: 1, iconOnlyCount: 2, visible: true },
496 { hiddenCount: 0, iconOnlyCount: 2, visible: true },
497 ],
498 ] as const) {
499 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth, previous })).toEqual(
500 previous,
501 );
502 expect(
503 resolveRestingComposerControlsLayout({
504 ...measurement,
505 hostWidth: hostWidth + 1,
506 previous,
507 }),
508 ).toEqual(promoted);
509 }
510 });
511
512 it("settles through fractional label-width changes at each threshold", () => {
513 for (const hostWidth of [368, 288, 248]) {
514 let previous = resolveRestingComposerControlsLayout({ ...measurement, hostWidth });
515 for (let index = 0; index < 10; index += 1) {
516 const next = resolveRestingComposerControlsLayout({
517 ...measurement,
518 hostWidth,
519 previous,
520 naturalFixedWidth: 140 + (index % 2) * 0.5,
521 });
522 if (index > 1) expect(next).toEqual(previous);
523 previous = next;
524 }
525 }
526 });
527});
apps/web/src/components/composerFooterLayout.ts+42-28
...@@ -113,18 +113,29 @@ export interface RestingComposerControlsMeasurement {...@@ -113,18 +113,29 @@ export interface RestingComposerControlsMeasurement {
113 minimumFixedWidth: number;113 minimumFixedWidth: number;
114 blockWidths: readonly number[];114 blockWidths: readonly number[];
115 overflowWidth: number;115 overflowWidth: number;
116 iconOnlyBlockWidths?: readonly number[];
116}117}
117118
118function restingComposerControlsWidth(119function restingComposerControlsWidth(
119 input: RestingComposerControlsMeasurement,120 input: RestingComposerControlsMeasurement,
120 hiddenCount: number,121 hiddenCount: number,
121 fixedWidth = input.naturalFixedWidth,122 fixedWidth = input.naturalFixedWidth,
123 iconOnlyCount = 0,
122): number {124): number {
123 const { blockWidths, gap } = input;125 const { blockWidths, gap } = input;
124 const visibleCount = blockWidths.length - hiddenCount;126 const visibleCount = blockWidths.length - hiddenCount;
125 return (127 return (
126 fixedWidth +128 fixedWidth +
127 blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) +129 blockWidths
130 .slice(0, visibleCount)
131 .reduce(
132 (sum, width, index) =>
133 sum +
134 (index >= blockWidths.length - iconOnlyCount
135 ? (input.iconOnlyBlockWidths?.[index] ?? width)
136 : width),
137 0,
138 ) +
128 (hiddenCount > 0 ? input.overflowWidth : 0) +139 (hiddenCount > 0 ? input.overflowWidth : 0) +
129 gap * (visibleCount + (hiddenCount > 0 ? 1 : 0))140 gap * (visibleCount + (hiddenCount > 0 ? 1 : 0))
130 );141 );
...@@ -145,10 +156,11 @@ export function resolveRestingComposerControlsNaturalWidth(...@@ -145,10 +156,11 @@ export function resolveRestingComposerControlsNaturalWidth(
145}156}
146157
147/**158/**
148 * Decide how many trailing resting control blocks move into the overflow159 * Fit footer controls using natural widths: remove trailing labels first,
149 * menu, and whether the cluster can show at all, from natural widths.160 * then move trailing blocks into overflow. Resting and expanded share this
161 * decision, including the slack needed to safely restore controls.
150 *162 *
151 * Trailing blocks hide before the model picker shrinks. Once they are all in163 * Trailing blocks compact before the model picker shrinks. Once they are all in
152 * the overflow menu, the picker may contract to its minimum readable width;164 * the overflow menu, the picker may contract to its minimum readable width;
153 * below that the whole cluster hides rather than clipping.165 * below that the whole cluster hides rather than clipping.
154 */166 */
...@@ -157,39 +169,41 @@ const RESTING_CONTROLS_SLACK_PX = 1;...@@ -157,39 +169,41 @@ const RESTING_CONTROLS_SLACK_PX = 1;
157export function resolveRestingComposerControlsLayout(169export function resolveRestingComposerControlsLayout(
158 input: RestingComposerControlsMeasurement & {170 input: RestingComposerControlsMeasurement & {
159 hostWidth: number;171 hostWidth: number;
160 previous?: { hiddenCount: number; visible: boolean };172 previous?: { hiddenCount: number; iconOnlyCount?: number; visible: boolean };
161 },173 },
162): { hiddenCount: number; visible: boolean } {174): { hiddenCount: number; iconOnlyCount?: number; visible: boolean } {
163 const { blockWidths, hostWidth, previous } = input;175 const { blockWidths, hostWidth, previous } = input;
164 let hiddenCount = 0;176 const iconSteps = input.iconOnlyBlockWidths ? blockWidths.length : 0;
177 const previousStep = previous
178 ? previous.hiddenCount > 0
179 ? iconSteps + Math.min(previous.hiddenCount, blockWidths.length)
180 : Math.min(previous.iconOnlyCount ?? 0, iconSteps)
181 : 0;
182 let step = 0;
183 const widthAtStep = (candidate: number, fixedWidth = input.naturalFixedWidth) =>
184 restingComposerControlsWidth(
185 input,
186 Math.max(0, candidate - iconSteps),
187 fixedWidth,
188 Math.min(candidate, iconSteps),
189 );
190 // Promotions need a pixel of slack: recovering a flexible picker's natural
191 // width can jitter by a fraction of a pixel across renders. Demotions are
192 // immediate so a threshold cannot clip or flip React between layouts.
165 while (193 while (
166 hiddenCount < blockWidths.length &&194 step < iconSteps + blockWidths.length &&
167 restingComposerControlsWidth(input, hiddenCount) > hostWidth195 widthAtStep(step) > hostWidth - (step < previousStep ? RESTING_CONTROLS_SLACK_PX : 0)
168 ) {196 ) {
169 hiddenCount += 1;197 step += 1;
170 }198 }
171 // Growing the overflow menu is unconditional, or the controls would clip.199 const hiddenCount = Math.max(0, step - iconSteps);
172 // Shrinking it has to earn a pixel of slack first: the picker is flexible,200 const iconOnlyCount = Math.min(step, iconSteps);
173 // so its natural width is recovered from a truncated label whose201 const minimumWidth = widthAtStep(step, input.minimumFixedWidth);
174 // scrollWidth is integral while the rendered box is fractional. The
175 // composer re-measures on every render, so without that margin a host
176 // sitting exactly on a threshold flips a block in and out until React
177 // gives up with "Maximum update depth exceeded".
178 if (previous) {
179 const previousHiddenCount = Math.min(previous.hiddenCount, blockWidths.length);
180 while (
181 hiddenCount < previousHiddenCount &&
182 restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX
183 ) {
184 hiddenCount += 1;
185 }
186 }
187 const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth);
188 const visible =202 const visible =
189 previous && !previous.visible203 previous && !previous.visible
190 ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX204 ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX
191 : minimumWidth <= hostWidth;205 : minimumWidth <= hostWidth;
192 return { hiddenCount, visible };206 return { hiddenCount, ...(input.iconOnlyBlockWidths ? { iconOnlyCount } : {}), visible };
193}207}
194208
195export function resolveScrollToEndClearance(input: {209export function resolveScrollToEndClearance(input: {