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 // Narrow composers deliberately collapse the label with w-0 and flex-none.
28 // A flexible label squeezed to zero still needs its natural width recovered.
29 const labelIsCollapsed = label?.clientWidth === 0 && getComputedStyle(label).flexGrow === "0";
30 const hiddenLabelWidth =
31 label && !labelIsCollapsed ? Math.max(0, label.scrollWidth - label.clientWidth) : 0;
32 const maxWidth = Number.parseFloat(style.maxWidth);
33 const naturalWidth = Math.min(
34 renderedWidth + hiddenLabelWidth,
35 Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY,
36 );
37 return naturalWidth + elementInlineMarginWidth(picker);
38}
39
40function providerModelPickerMinimumWidth(picker: HTMLElement): number {
41 const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0;
42 return minWidth + elementInlineMarginWidth(picker);
43}
44
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
72/**
73 * Read the natural widths of the resting composer controls from the DOM.
74 *
75 * Both the composer (deciding which blocks move into overflow) and the
76 * context strip (deciding whether its labels may expand) read the same
77 * numbers, so neither decision depends on what the other one hid last render.
78 *
79 * Hidden blocks and the unused overflow trigger stay mounted out of flow at
80 * full size. The picker is the one flexible item: its intended width is
81 * recovered from the truncated label.
82 */
83export function measureRestingComposerControls(
84 controls: HTMLElement,
85): RestingComposerControlsMeasurement | null {
86 const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0;
87 const picker = controls.querySelector<HTMLElement>("[data-chat-provider-model-picker]");
88 const leadingControl =
89 picker ?? controls.querySelector<HTMLElement>('[data-chat-provider-unavailable="true"]');
90 if (!leadingControl) return null;
91 // Separators are display:none on phone widths; a hidden one takes no gap.
92 const separator = controls.querySelector<HTMLElement>("[data-resting-controls-separator]");
93 const separatorWidth = separator ? elementOuterWidth(separator) : 0;
94 const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]");
95 const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0;
96 const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]"));
97 const widths = blocks.map(controlBlockWidths);
98 return {
99 gap,
100 naturalFixedWidth:
101 (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) +
102 separatorAndGapWidth,
103 minimumFixedWidth:
104 (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) +
105 separatorAndGapWidth,
106 blockWidths: widths.map((width) => width.natural),
107 iconOnlyBlockWidths: widths.map((width) => width.iconOnly),
108 overflowWidth: overflow ? elementOuterWidth(overflow) : 0,
109 };
110}