authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 18:55:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 18:55:43-07:00
log3c3e05ccfe34ab7af273f4a4faa72909cafd7a21
tree4c3b5b584304359345b87f956789cf5dece01642
parent54aef6fbe16f637092505b30bd25230c4b0744d8
signature Signed by PGP key B5690EEEBB952194

fix(web): measure collapsed model labels at their visible width (#9540)


2 files changed, 77 insertions(+), 1 deletions(-)

apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts created+72
......@@ -0,0 +1,72 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 resolveRestingComposerControlsLayout,
5 resolveRestingComposerControlsNaturalWidth,
6} from "../composerFooterLayout";
7import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";
8
9function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth?: string }) {
10 const label = { clientWidth: input.clientWidth, scrollWidth: 160 };
11 const picker = {
12 getBoundingClientRect: () => ({ width: 52 }),
13 querySelector: () => label,
14 };
15 const controls = {
16 querySelector: (selector: string) => {
17 if (selector === "[data-chat-provider-model-picker]") return picker;
18 if (selector === "[data-resting-controls-overflow]") {
19 return { getBoundingClientRect: () => ({ width: 24 }) };
20 }
21 return null;
22 },
23 querySelectorAll: () => [{ getBoundingClientRect: () => ({ width: 140 }) }],
24 };
25 vi.stubGlobal("getComputedStyle", (element: unknown) => {
26 if (element === label) return { flexGrow: input.flexGrow };
27 if (element === picker) return { minWidth: "52px", maxWidth: input.maxWidth ?? "none" };
28 return { columnGap: "4px" };
29 });
30 return measureRestingComposerControls(controls as unknown as HTMLElement)!;
31}
32
33afterEach(() => vi.unstubAllGlobals());
34
35describe("measureRestingComposerControls", () => {
36 it("keeps controls inline when the model label is deliberately collapsed", () => {
37 const measurement = measurePicker({ clientWidth: 0, flexGrow: "0" });
38
39 expect(measurement.naturalFixedWidth).toBe(52);
40 expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196);
41 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
42 hiddenCount: 0,
43 visible: true,
44 });
45 });
46
47 it("recovers truncated text while the model label is flexible", () => {
48 const measurement = measurePicker({ clientWidth: 20, flexGrow: "1" });
49
50 expect(measurement.naturalFixedWidth).toBe(192);
51 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
52 hiddenCount: 1,
53 visible: true,
54 });
55 });
56
57 it("recovers flexible text squeezed to zero instead of mistaking it for collapsed text", () => {
58 const measurement = measurePicker({ clientWidth: 0, flexGrow: "1" });
59
60 expect(measurement.naturalFixedWidth).toBe(212);
61 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
62 hiddenCount: 1,
63 visible: true,
64 });
65 });
66
67 it("still caps recovered text at the model picker's maximum width", () => {
68 expect(
69 measurePicker({ clientWidth: 0, flexGrow: "1", maxWidth: "180px" }).naturalFixedWidth,
70 ).toBe(180);
71 });
72});
apps/web/src/components/chat/restingComposerControlsMeasurement.ts+5-1
......@@ -24,7 +24,11 @@ function providerModelPickerNaturalWidth(picker: HTMLElement): number {
2424 if (renderedWidth === 0) return 0;
2525 const style = getComputedStyle(picker);
2626 const label = picker.querySelector<HTMLElement>('[data-chat-provider-model-picker-label="true"]');
27 const hiddenLabelWidth = label ? Math.max(0, label.scrollWidth - label.clientWidth) : 0;
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;
2832 const maxWidth = Number.parseFloat(style.maxWidth);
2933 const naturalWidth = Math.min(
3034 renderedWidth + hiddenLabelWidth,