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";
5353import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
5454import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
5555import {
56 Fragment,
5756 memo,
5857 type ComponentProps,
5958 type ReactNode,
......@@ -187,8 +186,6 @@ import {
187186import { useComposerPathSearch } from "../../lib/composerPathSearchState";
188187import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
189188import {
190 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
191 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
192189 getRestingComposerImagePreviewCounts,
193190 resolveRestingComposerControlsLayout,
194191 shouldAnimateComposerRestingTransition,
......@@ -197,7 +194,6 @@ import {
197194 shouldUseRestingComposerLayout,
198195} from "../composerFooterLayout";
199196import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";
200import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
201197import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
202198import {
203199 ComposerContextActionsContext,
......@@ -994,22 +990,32 @@ const extendReplacementRangeForTrailingSpace = (
994990 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
995991};
996992
997function useRestingComposerControlsLayout(host: HTMLDivElement | null) {
993function useRestingComposerControlsLayout(host: HTMLDivElement | null, useControlsAsHost = false) {
994 const [controls, setControls] = useState<HTMLDivElement | null>(null);
998995 const controlsRef = useRef<HTMLDivElement>(null);
996 const attachControls = useCallback((element: HTMLDivElement | null) => {
997 controlsRef.current = element;
998 setControls(element);
999 }, []);
9991000 const hostRef = useRef(host);
10001001 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
10031008 const measure = useCallback(() => {
1004 const currentHost = 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.
1009 const currentHost = useControlsAsHost ? controls : hostRef.current;
10081010 if (currentHost === null || !controls) return;
10091011
10101012 const measurement = measureRestingComposerControls(controls);
10111013 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
10141020 setLayout((current) => {
10151021 const next = resolveRestingComposerControlsLayout({
......@@ -1017,25 +1023,48 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) {
10171023 hostWidth,
10181024 previous: current,
10191025 });
1020 return next.hiddenCount === current.hiddenCount && next.visible === current.visible
1026 return next.hiddenCount === current.hiddenCount &&
1027 next.iconOnlyCount === current.iconOnlyCount &&
1028 next.visible === current.visible
10211029 ? current
10221030 : next;
10231031 });
1024 }, []);
1032 }, [controls, useControlsAsHost]);
10251033
1026 useLayoutEffect(measure);
1034 useLayoutEffect(measure, [measure, host]);
10271035 useEffect(() => {
1028 if (!host) return;
1036 const currentHost = useControlsAsHost ? controls : host;
1037 if (!currentHost || !controls) return;
10291038 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 });
10311053 document.fonts.addEventListener("loadingdone", measure);
10321054 return () => {
10331055 observer.disconnect();
1056 mutations.disconnect();
10341057 document.fonts.removeEventListener("loadingdone", measure);
10351058 };
1036 }, [host, measure]);
1037
1038 return { controlsRef, hiddenBlockCount: layout.hiddenCount, controlsVisible: layout.visible };
1059 }, [host, controls, useControlsAsHost, measure]);
1060
1061 return {
1062 controlsRef,
1063 attachControls,
1064 hiddenBlockCount: layout.hiddenCount,
1065 iconOnlyBlockCount: layout.iconOnlyCount ?? 0,
1066 controlsVisible: layout.visible,
1067 };
10391068}
10401069
10411070const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
......@@ -1092,7 +1121,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
10921121 opticalSize={size === "xs" ? "default" : "large"}
10931122 />
10941123 )}
1095 <span className="sr-only sm:not-sr-only">
1124 <span data-composer-control-label className="sr-only sm:not-sr-only">
10961125 {props.interactionMode === "plan" ? "Plan" : "Build"}
10971126 </span>
10981127 </TooltipTrigger>
......@@ -1123,7 +1152,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
11231152 }
11241153 >
11251154 <ComposerControlIcon icon={RuntimeModeIcon} size={size} />
1126 <SelectValue>{runtimeModeOption.label}</SelectValue>
1155 <SelectValue data-composer-control-label>{runtimeModeOption.label}</SelectValue>
11271156 </TooltipTrigger>
11281157 <SelectPopup alignItemWithTrigger={false} {...composerFloatingLayerProps}>
11291158 {runtimeModeOptions.map((mode) => {
......@@ -2104,8 +2133,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21042133 key: 0,
21052134 active: false,
21062135 });
2107 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
2108 usePanelAnimationSettings();
21092136 const isComposerCollapsedMobile =
21102137 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt;
21112138
......@@ -2120,7 +2147,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21202147 });
21212148 const attachmentInputRef = useRef<HTMLInputElement>(null);
21222149 const composerFormRef = useRef<HTMLFormElement>(null);
2123 const composerFooterControlsRef = useRef<HTMLDivElement>(null);
21242150 const composerSurfaceRef = useRef<HTMLDivElement>(null);
21252151 const providerInputRejectedRef = useRef(false);
21262152 const composerSelectLockRef = useRef(false);
......@@ -2628,9 +2654,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26282654 const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput);
26292655 const {
26302656 controlsRef: restingComposerControlsRef,
2657 attachControls: attachRestingComposerControls,
26312658 hiddenBlockCount: restingControlsHiddenBlockCount,
2659 iconOnlyBlockCount: restingControlsIconOnlyBlockCount,
26322660 controlsVisible: restingControlsVisible,
26332661 } = useRestingComposerControlsLayout(restingControlsHost);
2662 const expandedControlsLayout = useRestingComposerControlsLayout(null, true);
26342663 const pendingPrimaryAction = useMemo(
26352664 () =>
26362665 activePendingProgress
......@@ -3177,22 +3206,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31773206 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
31783207 setIsComposerFooterCompact(initialCompactness.footerCompact);
31793208 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
31963209 const observer = new ResizeObserver(() => {
31973210 const nextCompactness = measureFooterCompactness();
31983211 setIsComposerPrimaryActionsCompact((previous) =>
......@@ -3208,7 +3221,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
32083221 observer.observe(composerForm);
32093222 return () => {
32103223 observer.disconnect();
3211 stopFooterControlsFade?.();
32123224 };
32133225 }, [
32143226 activeThreadId,
......@@ -3216,8 +3228,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
32163228 composerFooterHasWideActions,
32173229 isComposerApprovalState,
32183230 isComposerCollapsedMobile,
3219 panelAnimationDurationMs,
3220 panelAnimationsActive,
32213231 ]);
32223232
32233233 // ------------------------------------------------------------------
......@@ -4926,11 +4936,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
49264936 setIsComposerScrollCollapsed,
49274937 ]);
49284938
4929 const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0;
4930 const composerControlsCompact = !composerControlsInStrip && isComposerFooterCompact;
4939 const restingHiddenBlockCount = composerControlsInStrip
4940 ? restingControlsHiddenBlockCount
4941 : expandedControlsLayout.hiddenBlockCount;
4942 const iconOnlyBlockCount = composerControlsInStrip
4943 ? restingControlsIconOnlyBlockCount
4944 : expandedControlsLayout.iconOnlyBlockCount;
49314945 const restingProviderTraitsPicker = renderProviderTraitsPicker({
49324946 ...providerTraitsPickerInput,
4933 size: "xs",
4947 size: composerControlsInStrip ? "xs" : "sm",
49344948 hidden: composerControlsHidden || restingHiddenBlockCount > 1,
49354949 });
49364950 const restingBlockDefs = [
......@@ -4941,7 +4955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
49414955 content: (
49424956 <>
49434957 <ComposerControlSeparator size={composerControlsInStrip ? "xs" : "sm"} />
4944 {composerControlsInStrip ? restingProviderTraitsPicker : providerTraitsPicker}
4958 {restingProviderTraitsPicker}
49454959 </>
49464960 ),
49474961 },
......@@ -5048,7 +5062,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
50485062 triggerClassName={
50495063 composerControlsInStrip
50505064 ? "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"
50525066 }
50535067 terminalOpen={terminalOpen}
50545068 open={isComposerModelPickerOpen}
......@@ -5075,65 +5089,52 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
50755089 onOpenProviderSetup={onOpenProviderSetup}
50765090 />
50775091
5078 {composerControlsCompact ? (
5079 <CompactComposerControlsMenu
5080 interactionMode={interactionMode}
5081 runtimeMode={runtimeMode}
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 ? (
5092 <>
5093 {restingBlockDefs.map((def, index) => {
5094 const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount;
5095 return (
51105096 <div
5111 data-resting-controls-overflow
5112 aria-hidden={hiddenRestingBlockIds.length === 0 || undefined}
5113 inert={hiddenRestingBlockIds.length === 0 || undefined}
5097 key={def.id}
5098 data-resting-block={def.id}
5099 data-composer-block-icon-only={
5100 index >= restingBlockDefs.length - iconOnlyBlockCount ? "true" : "false"
5101 }
5102 aria-hidden={hidden || undefined}
5103 inert={hidden || undefined}
51145104 className={cn(
5115 "min-w-0 shrink-0",
5116 hiddenRestingBlockIds.length === 0 && "pointer-events-none invisible absolute",
5105 "flex w-max min-w-max shrink-0 items-center gap-1",
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",
51175109 )}
51185110 >
5119 <CompactComposerControlsMenu
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 />
5111 {def.content}
51335112 </div>
5134 ) : null}
5135 </>
5136 )}
5113 );
5114 })}
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 </>
51375138 </>
51385139 );
51395140 const showTasksTab =
......@@ -6159,7 +6160,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
61596160 {composerControlsInStrip && restingControlsHost
61606161 ? createPortal(
61616162 <div
6162 ref={restingComposerControlsRef}
6163 ref={attachRestingComposerControls}
61636164 data-chat-composer-resting-controls="true"
61646165 aria-hidden={restingControlsVisible ? undefined : true}
61656166 inert={restingControlsVisible ? undefined : true}
......@@ -6961,11 +6962,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
69616962 )}
69626963 >
69636964 <div
6964 ref={composerFooterControlsRef}
6965 ref={expandedControlsLayout.attachControls}
69656966 data-chat-composer-controls="left"
69666967 data-chat-composer-footer-controls="true"
69676968 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",
69696970 isComposerResting && "hidden",
69706971 )}
69716972 >
apps/web/src/components/chat/TraitsPicker.tsx+63-34
......@@ -17,7 +17,7 @@ import {
1717} from "@t3tools/shared/model";
1818import { memo, useCallback } from "react";
1919import type { VariantProps } from "class-variance-authority";
20import { ZapIcon } from "lucide-react";
20import { GaugeIcon, ZapIcon } from "lucide-react";
2121import { buttonVariants } from "../ui/button";
2222import {
2323 Menu,
......@@ -32,6 +32,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
3232import { getProviderModelCapabilities } from "../../providerModels";
3333import { cn } from "~/lib/utils";
3434import { Badge } from "../ui/badge";
35import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3536import {
3637 ComposerControl,
3738 ComposerControlChevron,
......@@ -589,6 +590,7 @@ export const TraitsPicker = memo(function TraitsPicker({
589590 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
590591 ultrathinkPromptControlled,
591592 });
593 const accessibleLabel = showFastModeIcon ? `${triggerLabel}, Fast mode on` : triggerLabel;
592594 const fastModeIcon = showFastModeIcon ? (
593595 <>
594596 <ComposerControlIcon
......@@ -616,39 +618,66 @@ export const TraitsPicker = memo(function TraitsPicker({
616618 setIsMenuOpen(open);
617619 }}
618620 >
619 <MenuTrigger
620 render={
621 <ComposerControl
622 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
623 variant={triggerVariant ?? "ghost"}
624 size={size}
625 className={cn(
626 isCodexStyle
627 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap sm:max-w-48"
628 : "shrink-0 whitespace-nowrap",
629 triggerClassName,
630 )}
631 />
632 }
633 >
634 {isCodexStyle ? (
635 // The label truncates itself; clipping the wrapper too would cut off
636 // the chevron, whose negative end margin overhangs the wrapper edge.
637 <span
638 className={cn("flex min-w-0 w-full items-center", size === "xs" ? "gap-1" : "gap-1.5")}
639 >
640 {fastModeIcon}
641 <span className="min-w-0 truncate">{triggerLabel}</span>
642 <ComposerControlChevron size={size} />
643 </span>
644 ) : (
645 <>
646 {fastModeIcon}
647 <span>{triggerLabel}</span>
648 <ComposerControlChevron size={size} />
649 </>
650 )}
651 </MenuTrigger>
621 <Tooltip>
622 <TooltipTrigger
623 render={
624 <MenuTrigger
625 render={
626 <ComposerControl
627 aria-label={accessibleLabel}
628 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
629 variant={triggerVariant ?? "ghost"}
630 size={size}
631 className={cn(
632 isCodexStyle
633 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap sm:max-w-48"
634 : "shrink-0 whitespace-nowrap",
635 triggerClassName,
636 )}
637 />
638 }
639 />
640 }
641 >
642 {isCodexStyle ? (
643 // The label truncates itself; clipping the wrapper too would cut off
644 // the chevron, whose negative end margin overhangs the wrapper edge.
645 <span
646 className={cn(
647 "flex min-w-0 w-full items-center",
648 size === "xs" ? "gap-1" : "gap-1.5",
649 )}
650 >
651 {fastModeIcon ?? (
652 <span
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>
652681 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>
653682 <TraitsMenuContent
654683 provider={provider}
apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts+10-1
......@@ -20,7 +20,13 @@ function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth?
2020 }
2121 return null;
2222 },
23 querySelectorAll: () => [{ getBoundingClientRect: () => ({ width: 140 }) }],
23 querySelectorAll: () => [
24 {
25 dataset: {},
26 querySelectorAll: () => [],
27 getBoundingClientRect: () => ({ width: 140 }),
28 },
29 ],
2430 };
2531 vi.stubGlobal("getComputedStyle", (element: unknown) => {
2632 if (element === label) return { flexGrow: input.flexGrow };
......@@ -40,6 +46,7 @@ describe("measureRestingComposerControls", () => {
4046 expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196);
4147 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
4248 hiddenCount: 0,
49 iconOnlyCount: 0,
4350 visible: true,
4451 });
4552 });
......@@ -50,6 +57,7 @@ describe("measureRestingComposerControls", () => {
5057 expect(measurement.naturalFixedWidth).toBe(192);
5158 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
5259 hiddenCount: 1,
60 iconOnlyCount: 1,
5361 visible: true,
5462 });
5563 });
......@@ -60,6 +68,7 @@ describe("measureRestingComposerControls", () => {
6068 expect(measurement.naturalFixedWidth).toBe(212);
6169 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
6270 hiddenCount: 1,
71 iconOnlyCount: 1,
6372 visible: true,
6473 });
6574 });
apps/web/src/components/chat/restingComposerControlsMeasurement.ts+30-1
......@@ -42,6 +42,33 @@ function providerModelPickerMinimumWidth(picker: HTMLElement): number {
4242 return minWidth + elementInlineMarginWidth(picker);
4343}
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
4572/**
4673 * Read the natural widths of the resting composer controls from the DOM.
4774 *
......@@ -67,6 +94,7 @@ export function measureRestingComposerControls(
6794 const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]");
6895 const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0;
6996 const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]"));
97 const widths = blocks.map(controlBlockWidths);
7098 return {
7199 gap,
72100 naturalFixedWidth:
......@@ -75,7 +103,8 @@ export function measureRestingComposerControls(
75103 minimumFixedWidth:
76104 (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) +
77105 separatorAndGapWidth,
78 blockWidths: blocks.map(elementOuterWidth),
106 blockWidths: widths.map((width) => width.natural),
107 iconOnlyBlockWidths: widths.map((width) => width.iconOnly),
79108 overflowWidth: overflow ? elementOuterWidth(overflow) : 0,
80109 };
81110}
apps/web/src/components/composerFooterLayout.test.ts+76
......@@ -449,3 +449,79 @@ describe("resolveScrollToEndClearance", () => {
449449 }
450450 });
451451});
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 {
113113 minimumFixedWidth: number;
114114 blockWidths: readonly number[];
115115 overflowWidth: number;
116 iconOnlyBlockWidths?: readonly number[];
116117}
117118
118119function restingComposerControlsWidth(
119120 input: RestingComposerControlsMeasurement,
120121 hiddenCount: number,
121122 fixedWidth = input.naturalFixedWidth,
123 iconOnlyCount = 0,
122124): number {
123125 const { blockWidths, gap } = input;
124126 const visibleCount = blockWidths.length - hiddenCount;
125127 return (
126128 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 ) +
128139 (hiddenCount > 0 ? input.overflowWidth : 0) +
129140 gap * (visibleCount + (hiddenCount > 0 ? 1 : 0))
130141 );
......@@ -145,10 +156,11 @@ export function resolveRestingComposerControlsNaturalWidth(
145156}
146157
147158/**
148 * Decide how many trailing resting control blocks move into the overflow
149 * menu, and whether the cluster can show at all, from natural widths.
159 * Fit footer controls using natural widths: remove trailing labels first,
160 * then move trailing blocks into overflow. Resting and expanded share this
161 * decision, including the slack needed to safely restore controls.
150162 *
151 * Trailing blocks hide before the model picker shrinks. Once they are all in
163 * Trailing blocks compact before the model picker shrinks. Once they are all in
152164 * the overflow menu, the picker may contract to its minimum readable width;
153165 * below that the whole cluster hides rather than clipping.
154166 */
......@@ -157,39 +169,41 @@ const RESTING_CONTROLS_SLACK_PX = 1;
157169export function resolveRestingComposerControlsLayout(
158170 input: RestingComposerControlsMeasurement & {
159171 hostWidth: number;
160 previous?: { hiddenCount: number; visible: boolean };
172 previous?: { hiddenCount: number; iconOnlyCount?: number; visible: boolean };
161173 },
162): { hiddenCount: number; visible: boolean } {
174): { hiddenCount: number; iconOnlyCount?: number; visible: boolean } {
163175 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.
165193 while (
166 hiddenCount < blockWidths.length &&
167 restingComposerControlsWidth(input, hiddenCount) > hostWidth
194 step < iconSteps + blockWidths.length &&
195 widthAtStep(step) > hostWidth - (step < previousStep ? RESTING_CONTROLS_SLACK_PX : 0)
168196 ) {
169 hiddenCount += 1;
197 step += 1;
170198 }
171 // Growing the overflow menu is unconditional, or the controls would clip.
172 // Shrinking it has to earn a pixel of slack first: the picker is flexible,
173 // so its natural width is recovered from a truncated label whose
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);
199 const hiddenCount = Math.max(0, step - iconSteps);
200 const iconOnlyCount = Math.min(step, iconSteps);
201 const minimumWidth = widthAtStep(step, input.minimumFixedWidth);
188202 const visible =
189203 previous && !previous.visible
190204 ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX
191205 : minimumWidth <= hostWidth;
192 return { hiddenCount, visible };
206 return { hiddenCount, ...(input.iconOnlyBlockWidths ? { iconOnlyCount } : {}), visible };
193207}
194208
195209export function resolveScrollToEndClearance(input: {