authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 02:33:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 02:33:37-07:00
log044ea8e347d980a215057c325de8335d38f0f9aa
treea4819e36b4b996bdf53637321ea1bf2674ec71c3
parent18573d60aab1344bf124a044b01445086e910c4c
signature Signed by PGP key B5690EEEBB952194

fix(web): stop the resting composer layout loop (#9393)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

7 files changed, 245 insertions(+), 102 deletions(-)

apps/web/src/components/BranchToolbar.logic.ts+14
...@@ -68,6 +68,20 @@ export function shouldShowComposerContextStrip(input: {...@@ -68,6 +68,20 @@ export function shouldShowComposerContextStrip(input: {
68 );68 );
69}69}
7070
71// Labels collapse to icons when the strip's content no longer fits. A small
72// hysteresis on the way back out keeps the boundary from flapping.
73const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16;
74
75export function resolveContextStripLabelsCompact(input: {
76 compact: boolean;
77 neededWidth: number;
78 availableWidth: number;
79}): boolean {
80 return input.compact
81 ? input.neededWidth > input.availableWidth - CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX
82 : input.neededWidth > input.availableWidth;
83}
84
71export function resolveEnvModeLabel(mode: EnvMode): string {85export function resolveEnvModeLabel(mode: EnvMode): string {
72 return mode === "worktree" ? "New worktree" : "Current checkout";86 return mode === "worktree" ? "New worktree" : "Current checkout";
73}87}
apps/web/src/components/BranchToolbar.tsx+19-7
...@@ -15,6 +15,7 @@ import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/e...@@ -15,6 +15,7 @@ import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/e
15import {15import {
16 type EnvMode,16 type EnvMode,
17 type EnvironmentOption,17 type EnvironmentOption,
18 resolveContextStripLabelsCompact,
18 resolveCurrentWorkspaceLabel,19 resolveCurrentWorkspaceLabel,
19 resolveEnvModeLabel,20 resolveEnvModeLabel,
20 resolveEffectiveEnvMode,21 resolveEffectiveEnvMode,
...@@ -39,6 +40,8 @@ import {...@@ -39,6 +40,8 @@ import {
39} from "./ui/menu";40} from "./ui/menu";
40import { Separator } from "./ui/separator";41import { Separator } from "./ui/separator";
41import { ComposerSurface } from "./chat/ComposerSurface";42import { ComposerSurface } from "./chat/ComposerSurface";
43import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
44import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
42import { cn } from "~/lib/utils";45import { cn } from "~/lib/utils";
4346
44interface BranchToolbarProps {47interface BranchToolbarProps {
...@@ -236,7 +239,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -236,7 +239,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
236 * the expanded width without remembered values that could go stale or latch239 * the expanded width without remembered values that could go stale or latch
237 * the strip compact. A small hysteresis keeps the boundary from flapping.240 * the strip compact. A small hysteresis keeps the boundary from flapping.
238 */241 */
239const COMPACT_EXPAND_HYSTERESIS_PX = 16;
240const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;242const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
241const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";243const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
242const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]";244const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]";
...@@ -282,12 +284,20 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -282,12 +284,20 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
282 let groups = 0;284 let groups = 0;
283 for (const child of current.children) {285 for (const child of current.children) {
284 if (!(child instanceof HTMLElement)) continue;286 if (!(child instanceof HTMLElement)) continue;
285 // The host itself flexes into all remaining room. Measure the controls287 // The host itself flexes into all remaining room. Reserve the natural
286 // inside it so Git labels compact before squeezing out the model picker.288 // width of the controls inside it, blocks in overflow included, so Git
289 // labels compact before squeezing out the model picker. Reserving only
290 // the visible controls would let the labels expand into room the
291 // composer just freed, shrink the host, and hide the controls again.
287 const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]')292 const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]')
288 ? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]')293 ? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]')
289 : null;294 : null;
290 const width = hostedControls ? contentWidth(hostedControls) : contentWidth(child);295 const hostedMeasurement = hostedControls
296 ? measureRestingComposerControls(hostedControls)
297 : null;
298 const width = hostedMeasurement
299 ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement)
300 : contentWidth(hostedControls ?? child);
291 if (width <= 1) continue;301 if (width <= 1) continue;
292 groups += 1;302 groups += 1;
293 needed += width;303 needed += width;
...@@ -311,9 +321,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -311,9 +321,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
311 needed += Math.max(0, textWidth - label.clientWidth);321 needed += Math.max(0, textWidth - label.clientWidth);
312 }322 }
313 }323 }
314 const nextOverflows = compact324 const nextOverflows = resolveContextStripLabelsCompact({
315 ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX325 compact,
316 : needed > available;326 neededWidth: needed,
327 availableWidth: available,
328 });
317 if (nextOverflows !== compact) {329 if (nextOverflows !== compact) {
318 pendingControlRectsRef.current = new Map(330 pendingControlRectsRef.current = new Map(
319 Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map(331 Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map(
apps/web/src/components/chat/ChatComposer.tsx+6-72
...@@ -150,6 +150,7 @@ import {...@@ -150,6 +150,7 @@ import {
150 shouldUseCompactComposerFooter,150 shouldUseCompactComposerFooter,
151 shouldUseRestingComposerLayout,151 shouldUseRestingComposerLayout,
152} from "../composerFooterLayout";152} from "../composerFooterLayout";
153import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";
153import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";154import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations";
154import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";155import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
155import { ProviderModelPicker } from "./ProviderModelPicker";156import { ProviderModelPicker } from "./ProviderModelPicker";
...@@ -852,44 +853,6 @@ const terminalContextIdListsEqual = (...@@ -852,44 +853,6 @@ const terminalContextIdListsEqual = (
852): boolean =>853): boolean =>
853 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);854 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
854855
855function elementOuterWidth(element: HTMLElement): number {
856 const width = element.getBoundingClientRect().width;
857 if (width === 0) return 0;
858 const style = getComputedStyle(element);
859 return (
860 width +
861 (Number.parseFloat(style.marginInlineStart) || 0) +
862 (Number.parseFloat(style.marginInlineEnd) || 0)
863 );
864}
865
866function elementInlineMarginWidth(element: HTMLElement): number {
867 const style = getComputedStyle(element);
868 return (
869 (Number.parseFloat(style.marginInlineStart) || 0) +
870 (Number.parseFloat(style.marginInlineEnd) || 0)
871 );
872}
873
874function providerModelPickerNaturalWidth(picker: HTMLElement): number {
875 const renderedWidth = picker.getBoundingClientRect().width;
876 if (renderedWidth === 0) return 0;
877 const style = getComputedStyle(picker);
878 const label = picker.querySelector<HTMLElement>('[data-chat-provider-model-picker-label="true"]');
879 const hiddenLabelWidth = label ? Math.max(0, label.scrollWidth - label.clientWidth) : 0;
880 const maxWidth = Number.parseFloat(style.maxWidth);
881 const naturalWidth = Math.min(
882 renderedWidth + hiddenLabelWidth,
883 Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY,
884 );
885 return naturalWidth + elementInlineMarginWidth(picker);
886}
887
888function providerModelPickerMinimumWidth(picker: HTMLElement): number {
889 const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0;
890 return minWidth + elementInlineMarginWidth(picker);
891}
892
893function useRestingComposerControlsLayout(host: HTMLDivElement | null) {856function useRestingComposerControlsLayout(host: HTMLDivElement | null) {
894 const controlsRef = useRef<HTMLDivElement>(null);857 const controlsRef = useRef<HTMLDivElement>(null);
895 const hostRef = useRef(host);858 const hostRef = useRef(host);
...@@ -903,41 +866,12 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) {...@@ -903,41 +866,12 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) {
903 // composer pays no layout reads here despite the every-render effect.866 // composer pays no layout reads here despite the every-render effect.
904 if (currentHost === null || !controls) return;867 if (currentHost === null || !controls) return;
905868
906 const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]"));869 const measurement = measureRestingComposerControls(controls);
907870 if (!measurement) return;
908 // Hidden blocks and the unused overflow trigger stay mounted out of flow
909 // at full size. The picker is the one flexible item: recover its intended
910 // width from the truncated label, then let it contract only after all
911 // trailing blocks have moved into overflow.
912 const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0;
913 const picker = controls.querySelector<HTMLElement>("[data-chat-provider-model-picker]");
914 const leadingControl =
915 picker ?? controls.querySelector<HTMLElement>('[data-chat-provider-unavailable="true"]');
916 if (!leadingControl) return;
917 // Separators are display:none on phone widths; a hidden one takes no gap.
918 const separator = controls.querySelector<HTMLElement>("[data-resting-controls-separator]");
919 const separatorWidth = separator ? elementOuterWidth(separator) : 0;
920 const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]");
921 const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0;
922 const naturalFixedWidth =
923 (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) +
924 separatorAndGapWidth;
925 const minimumFixedWidth =
926 (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) +
927 separatorAndGapWidth;
928 const blockWidths = blocks.map(elementOuterWidth);
929 const overflowWidth = overflow ? elementOuterWidth(overflow) : 0;
930 const hostWidth = currentHost.clientWidth;871 const hostWidth = currentHost.clientWidth;
931872
932 setLayout((current) => {873 setLayout((current) => {
933 const next = resolveRestingComposerControlsLayout({874 const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth });
934 hostWidth,
935 gap,
936 naturalFixedWidth,
937 minimumFixedWidth,
938 blockWidths,
939 overflowWidth,
940 });
941 return next.hiddenCount === current.hiddenCount && next.visible === current.visible875 return next.hiddenCount === current.hiddenCount && next.visible === current.visible
942 ? current876 ? current
943 : next;877 : next;
...@@ -3921,7 +3855,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3921,7 +3855,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3921 const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount;3855 const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount;
3922 return (3856 return (
3923 <div3857 <div
3924 key={`${def.id}:${hidden ? "hidden" : "visible"}`}3858 key={def.id}
3925 data-resting-block={def.id}3859 data-resting-block={def.id}
3926 aria-hidden={hidden || undefined}3860 aria-hidden={hidden || undefined}
3927 inert={hidden || undefined}3861 inert={hidden || undefined}
...@@ -3936,7 +3870,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3936,7 +3870,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3936 })}3870 })}
3937 {composerControlsInStrip ? (3871 {composerControlsInStrip ? (
3938 <div3872 <div
3939 key={hiddenRestingBlockIds.length > 0 ? "overflow:visible" : "overflow:hidden"}
3940 data-resting-controls-overflow3873 data-resting-controls-overflow
3941 aria-hidden={hiddenRestingBlockIds.length === 0 || undefined}3874 aria-hidden={hiddenRestingBlockIds.length === 0 || undefined}
3942 inert={hiddenRestingBlockIds.length === 0 || undefined}3875 inert={hiddenRestingBlockIds.length === 0 || undefined}
...@@ -3949,6 +3882,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3949,6 +3882,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3949 interactionMode={interactionMode}3882 interactionMode={interactionMode}
3950 runtimeMode={runtimeMode}3883 runtimeMode={runtimeMode}
3951 size="xs"3884 size="xs"
3885 hidden={hiddenRestingBlockIds.length === 0}
3952 showInteractionModeToggle={3886 showInteractionModeToggle={
3953 composerProviderControls.showInteractionModeToggle &&3887 composerProviderControls.showInteractionModeToggle &&
3954 hiddenRestingBlockIds.includes("mode")3888 hiddenRestingBlockIds.includes("mode")
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+18-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";1import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";
2import { memo, type ReactNode } from "react";2import { memo, type ReactNode, useState } from "react";
3import { EllipsisIcon } from "lucide-react";3import { EllipsisIcon } from "lucide-react";
4import {4import {
5 Menu,5 Menu,
...@@ -18,13 +18,29 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls...@@ -18,13 +18,29 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
18 showInteractionModeToggle: boolean;18 showInteractionModeToggle: boolean;
19 traitsMenuContent?: ReactNode;19 traitsMenuContent?: ReactNode;
20 size?: "sm" | "xs";20 size?: "sm" | "xs";
21 /**
22 * The resting strip keeps this menu mounted out of flow while every block
23 * fits inline. Its portaled popup would outlive that transition, so an
24 * open menu closes when its trigger hides.
25 */
26 hidden?: boolean;
21 onToggleInteractionMode: () => void;27 onToggleInteractionMode: () => void;
22 onRuntimeModeChange: (mode: RuntimeMode) => void;28 onRuntimeModeChange: (mode: RuntimeMode) => void;
23}) {29}) {
24 const size = props.size ?? "sm";30 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 }
2541
26 return (42 return (
27 <Menu>43 <Menu open={open && !hidden} onOpenChange={setOpen}>
28 <MenuTrigger44 <MenuTrigger
29 render={45 render={
30 <ComposerControl46 <ComposerControl
apps/web/src/components/chat/restingComposerControlsMeasurement.ts created+77
...@@ -0,0 +1,77 @@
1import type { RestingComposerControlsMeasurement } from "../composerFooterLayout";
2
3function elementOuterWidth(element: HTMLElement): number {
4 const width = element.getBoundingClientRect().width;
5 if (width === 0) return 0;
6 const style = getComputedStyle(element);
7 return (
8 width +
9 (Number.parseFloat(style.marginInlineStart) || 0) +
10 (Number.parseFloat(style.marginInlineEnd) || 0)
11 );
12}
13
14function elementInlineMarginWidth(element: HTMLElement): number {
15 const style = getComputedStyle(element);
16 return (
17 (Number.parseFloat(style.marginInlineStart) || 0) +
18 (Number.parseFloat(style.marginInlineEnd) || 0)
19 );
20}
21
22function providerModelPickerNaturalWidth(picker: HTMLElement): number {
23 const renderedWidth = picker.getBoundingClientRect().width;
24 if (renderedWidth === 0) return 0;
25 const style = getComputedStyle(picker);
26 const label = picker.querySelector<HTMLElement>('[data-chat-provider-model-picker-label="true"]');
27 const hiddenLabelWidth = label ? Math.max(0, label.scrollWidth - label.clientWidth) : 0;
28 const maxWidth = Number.parseFloat(style.maxWidth);
29 const naturalWidth = Math.min(
30 renderedWidth + hiddenLabelWidth,
31 Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY,
32 );
33 return naturalWidth + elementInlineMarginWidth(picker);
34}
35
36function providerModelPickerMinimumWidth(picker: HTMLElement): number {
37 const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0;
38 return minWidth + elementInlineMarginWidth(picker);
39}
40
41/**
42 * Read the natural widths of the resting composer controls from the DOM.
43 *
44 * Both the composer (deciding which blocks move into overflow) and the
45 * context strip (deciding whether its labels may expand) read the same
46 * numbers, so neither decision depends on what the other one hid last render.
47 *
48 * Hidden blocks and the unused overflow trigger stay mounted out of flow at
49 * full size. The picker is the one flexible item: its intended width is
50 * recovered from the truncated label.
51 */
52export function measureRestingComposerControls(
53 controls: HTMLElement,
54): RestingComposerControlsMeasurement | null {
55 const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0;
56 const picker = controls.querySelector<HTMLElement>("[data-chat-provider-model-picker]");
57 const leadingControl =
58 picker ?? controls.querySelector<HTMLElement>('[data-chat-provider-unavailable="true"]');
59 if (!leadingControl) return null;
60 // Separators are display:none on phone widths; a hidden one takes no gap.
61 const separator = controls.querySelector<HTMLElement>("[data-resting-controls-separator]");
62 const separatorWidth = separator ? elementOuterWidth(separator) : 0;
63 const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]");
64 const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0;
65 const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]"));
66 return {
67 gap,
68 naturalFixedWidth:
69 (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) +
70 separatorAndGapWidth,
71 minimumFixedWidth:
72 (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) +
73 separatorAndGapWidth,
74 blockWidths: blocks.map(elementOuterWidth),
75 overflowWidth: overflow ? elementOuterWidth(overflow) : 0,
76 };
77}
apps/web/src/components/composerFooterLayout.test.ts+64
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic";
3import {4import {
4 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,5 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
5 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,6 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
6 getRestingComposerImagePreviewCounts,7 getRestingComposerImagePreviewCounts,
7 resolveRestingComposerControlsLayout,8 resolveRestingComposerControlsLayout,
9 resolveRestingComposerControlsNaturalWidth,
8 shouldAnimateComposerRestingTransition,10 shouldAnimateComposerRestingTransition,
9 shouldUseCompactComposerPrimaryActions,11 shouldUseCompactComposerPrimaryActions,
10 shouldUseCompactComposerFooter,12 shouldUseCompactComposerFooter,
...@@ -213,3 +215,65 @@ describe("resolveRestingComposerControlsLayout", () => {...@@ -213,3 +215,65 @@ describe("resolveRestingComposerControlsLayout", () => {
213 ).toEqual({ hiddenCount: 0, visible: false });215 ).toEqual({ hiddenCount: 0, visible: false });
214 });216 });
215});217});
218
219describe("context strip labels and resting composer controls", () => {
220 // Widths captured from a desktop renderer that crashed with React error
221 // 185. The strip is 724px wide. Its expanded labels need 327px, and the
222 // rest of its chrome needs 125px. The composer controls sit in the host
223 // that takes whatever is left.
224 const stripWidth = 724;
225 const labelWidth = 327;
226 const chromeWidth = 125;
227 const measurement = {
228 gap: 4,
229 naturalFixedWidth: 96.3828125 + 5 + 4,
230 minimumFixedWidth: 52 + 5 + 4,
231 blockWidths: [130.6953125, 119.671875],
232 overflowWidth: 28,
233 };
234 const naturalWidth = resolveRestingComposerControlsNaturalWidth(measurement);
235
236 function hostWidth(compact: boolean): number {
237 return stripWidth - chromeWidth - (compact ? 0 : labelWidth);
238 }
239
240 it("keeps the labels compact when the full controls only fit beside compact labels", () => {
241 // Compact labels leave 599px, so the composer shows every block.
242 const layout = resolveRestingComposerControlsLayout({
243 ...measurement,
244 hostWidth: hostWidth(true),
245 });
246 expect(layout).toEqual({ hiddenCount: 0, visible: true });
247
248 // The strip reserves the natural controls width, so expanding the
249 // labels is off the table: 125 + 327 + 364 > 724.
250 const compact = resolveContextStripLabelsCompact({
251 compact: true,
252 neededWidth: chromeWidth + labelWidth + naturalWidth,
253 availableWidth: stripWidth,
254 });
255 expect(compact).toBe(true);
256
257 // The next pass sees the same inputs and lands on the same answer.
258 expect(
259 resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(compact) }),
260 ).toEqual(layout);
261 });
262
263 it("does not settle when the strip only reserves the visible controls", () => {
264 // Regression guard for the alternating layout. Reserving only the
265 // controls left visible after two blocks moved into overflow makes the
266 // strip expand its labels, which shrinks the host below what the full
267 // controls need, which hides the blocks again.
268 const hiddenControlsWidth = 137;
269 const expands = !resolveContextStripLabelsCompact({
270 compact: true,
271 neededWidth: chromeWidth + labelWidth + hiddenControlsWidth,
272 availableWidth: stripWidth,
273 });
274 expect(expands).toBe(true);
275 expect(
276 resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(false) }),
277 ).toEqual({ hiddenCount: 2, visible: true });
278 });
279});
apps/web/src/components/composerFooterLayout.ts+47-21
...@@ -63,6 +63,43 @@ export function shouldUseCompactComposerPrimaryActions(...@@ -63,6 +63,43 @@ export function shouldUseCompactComposerPrimaryActions(
63 return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;63 return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;
64}64}
6565
66export interface RestingComposerControlsMeasurement {
67 gap: number;
68 naturalFixedWidth: number;
69 minimumFixedWidth: number;
70 blockWidths: readonly number[];
71 overflowWidth: number;
72}
73
74function restingComposerControlsWidth(
75 input: RestingComposerControlsMeasurement,
76 hiddenCount: number,
77 fixedWidth = input.naturalFixedWidth,
78): number {
79 const { blockWidths, gap } = input;
80 const visibleCount = blockWidths.length - hiddenCount;
81 return (
82 fixedWidth +
83 blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) +
84 (hiddenCount > 0 ? input.overflowWidth : 0) +
85 gap * (visibleCount + (hiddenCount > 0 ? 1 : 0))
86 );
87}
88
89/**
90 * The width the resting controls take with nothing moved into overflow.
91 *
92 * The context strip reserves this much for the composer before deciding
93 * whether its own labels may expand. Judging against the currently visible
94 * controls instead lets the strip expand into space the composer just gave
95 * up, which shrinks the host, hides the controls again, and repeats.
96 */
97export function resolveRestingComposerControlsNaturalWidth(
98 input: RestingComposerControlsMeasurement,
99): number {
100 return restingComposerControlsWidth(input, 0);
101}
102
66/**103/**
67 * Decide how many trailing resting control blocks move into the overflow104 * Decide how many trailing resting control blocks move into the overflow
68 * menu, and whether the cluster can show at all, from natural widths.105 * menu, and whether the cluster can show at all, from natural widths.
...@@ -71,29 +108,18 @@ export function shouldUseCompactComposerPrimaryActions(...@@ -71,29 +108,18 @@ export function shouldUseCompactComposerPrimaryActions(
71 * the overflow menu, the picker may contract to its minimum readable width;108 * the overflow menu, the picker may contract to its minimum readable width;
72 * below that the whole cluster hides rather than clipping.109 * below that the whole cluster hides rather than clipping.
73 */110 */
74export function resolveRestingComposerControlsLayout(input: {111export function resolveRestingComposerControlsLayout(
75 hostWidth: number;112 input: RestingComposerControlsMeasurement & { hostWidth: number },
76 gap: number;113): { hiddenCount: number; visible: boolean } {
77 naturalFixedWidth: number;114 const { blockWidths, hostWidth } = input;
78 minimumFixedWidth: number;
79 blockWidths: readonly number[];
80 overflowWidth: number;
81}): { hiddenCount: number; visible: boolean } {
82 const { blockWidths, gap, hostWidth } = input;
83 const widthWithHidden = (hidden: number, fixedWidth = input.naturalFixedWidth) => {
84 const visibleCount = blockWidths.length - hidden;
85 return (
86 fixedWidth +
87 blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) +
88 (hidden > 0 ? input.overflowWidth : 0) +
89 gap * (visibleCount + (hidden > 0 ? 1 : 0))
90 );
91 };
92
93 let hiddenCount = 0;115 let hiddenCount = 0;
94 while (hiddenCount < blockWidths.length && widthWithHidden(hiddenCount) > hostWidth) {116 while (
117 hiddenCount < blockWidths.length &&
118 restingComposerControlsWidth(input, hiddenCount) > hostWidth
119 ) {
95 hiddenCount += 1;120 hiddenCount += 1;
96 }121 }
97 const visible = widthWithHidden(hiddenCount, input.minimumFixedWidth) <= hostWidth;122 const visible =
123 restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth) <= hostWidth;
98 return { hiddenCount, visible };124 return { hiddenCount, visible };
99}125}