authorgravatar for matheusfild4@hotmail.comMatheus Timbó Pereira <matheusfild4@hotmail.com> 2026-09-04 01:23:52+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 01:23:52+00:00
log0ba06a122bc73f705bfc18875366b4eeb1160992
treeeb29343d24aec4b524f6fa646fd211bf5e973dea
parentf54ab901fa77f76eeb1e1cdfa103b9ed6f5ee4ca
signature Signed by PGP key B5690EEEBB952194

fix(web): settle the resting composer layout with a pixel of slack (#9482)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

3 files changed, 107 insertions(+), 4 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+5-1
...@@ -877,7 +877,11 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) {...@@ -877,7 +877,11 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) {
877 const hostWidth = currentHost.clientWidth;877 const hostWidth = currentHost.clientWidth;
878878
879 setLayout((current) => {879 setLayout((current) => {
880 const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth });880 const next = resolveRestingComposerControlsLayout({
881 ...measurement,
882 hostWidth,
883 previous: current,
884 });
881 return next.hiddenCount === current.hiddenCount && next.visible === current.visible885 return next.hiddenCount === current.hiddenCount && next.visible === current.visible
882 ? current886 ? current
883 : next;887 : next;
apps/web/src/components/composerFooterLayout.test.ts+79
...@@ -300,3 +300,82 @@ describe("context strip labels and resting composer controls", () => {...@@ -300,3 +300,82 @@ describe("context strip labels and resting composer controls", () => {
300 ).toEqual({ hiddenCount: 2, visible: true });300 ).toEqual({ hiddenCount: 2, visible: true });
301 });301 });
302});302});
303
304describe("resolveRestingComposerControlsLayout hysteresis", () => {
305 // Same cluster as above: picker 140 natural / 96 minimum plus a 9px
306 // separator, traits 60, mode 140, overflow 24, gap 4.
307 const base = {
308 gap: 4,
309 naturalFixedWidth: 149,
310 minimumFixedWidth: 105,
311 blockWidths: [60, 140],
312 overflowWidth: 24,
313 };
314
315 it("keeps a block in overflow when re-showing it would leave no slack", () => {
316 // 149 + 60 + 140 + 4 * 2 = 357 fills the host exactly.
317 expect(
318 resolveRestingComposerControlsLayout({
319 ...base,
320 hostWidth: 357,
321 previous: { hiddenCount: 1, visible: true },
322 }),
323 ).toEqual({ hiddenCount: 1, visible: true });
324 });
325
326 it("re-shows a block once the host clears the slack margin", () => {
327 expect(
328 resolveRestingComposerControlsLayout({
329 ...base,
330 hostWidth: 358,
331 previous: { hiddenCount: 1, visible: true },
332 }),
333 ).toEqual({ hiddenCount: 0, visible: true });
334 });
335
336 it("still resolves from scratch when there is no previous layout", () => {
337 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({
338 hiddenCount: 0,
339 visible: true,
340 });
341 });
342
343 it("settles when the measured picker width jitters below a pixel", () => {
344 // Regression guard for React error #185 ("Maximum update depth
345 // exceeded"). The composer re-measures on every render, so the picker's
346 // recovered natural width can land a fraction of a pixel apart between
347 // renders. Without hysteresis that flips hiddenCount forever.
348 let layout = { hiddenCount: 0, visible: true };
349 const settled: string[] = [];
350 for (let index = 0; index < 10; index += 1) {
351 layout = resolveRestingComposerControlsLayout({
352 ...base,
353 // The picker's recovered natural width lands a half pixel apart
354 // between renders.
355 naturalFixedWidth: index % 2 === 0 ? 149 : 149.5,
356 hostWidth: 357,
357 previous: layout,
358 });
359 if (index >= 2) settled.push(`${layout.hiddenCount}:${layout.visible}`);
360 }
361 expect(new Set(settled).size).toBe(1);
362 });
363
364 it("keeps the cluster hidden until its minimum width clears the slack", () => {
365 // 105 + 24 + 4 = 133 is the exact minimum for the hidden cluster.
366 expect(
367 resolveRestingComposerControlsLayout({
368 ...base,
369 hostWidth: 133,
370 previous: { hiddenCount: 2, visible: false },
371 }),
372 ).toEqual({ hiddenCount: 2, visible: false });
373 expect(
374 resolveRestingComposerControlsLayout({
375 ...base,
376 hostWidth: 134,
377 previous: { hiddenCount: 2, visible: false },
378 }),
379 ).toEqual({ hiddenCount: 2, visible: true });
380 });
381});
apps/web/src/components/composerFooterLayout.ts+23-3
...@@ -111,10 +111,15 @@ export function resolveRestingComposerControlsNaturalWidth(...@@ -111,10 +111,15 @@ export function resolveRestingComposerControlsNaturalWidth(
111 * the overflow menu, the picker may contract to its minimum readable width;111 * the overflow menu, the picker may contract to its minimum readable width;
112 * below that the whole cluster hides rather than clipping.112 * below that the whole cluster hides rather than clipping.
113 */113 */
114const RESTING_CONTROLS_SLACK_PX = 1;
115
114export function resolveRestingComposerControlsLayout(116export function resolveRestingComposerControlsLayout(
115 input: RestingComposerControlsMeasurement & { hostWidth: number },117 input: RestingComposerControlsMeasurement & {
118 hostWidth: number;
119 previous?: { hiddenCount: number; visible: boolean };
120 },
116): { hiddenCount: number; visible: boolean } {121): { hiddenCount: number; visible: boolean } {
117 const { blockWidths, hostWidth } = input;122 const { blockWidths, hostWidth, previous } = input;
118 let hiddenCount = 0;123 let hiddenCount = 0;
119 while (124 while (
120 hiddenCount < blockWidths.length &&125 hiddenCount < blockWidths.length &&
...@@ -122,7 +127,22 @@ export function resolveRestingComposerControlsLayout(...@@ -122,7 +127,22 @@ export function resolveRestingComposerControlsLayout(
122 ) {127 ) {
123 hiddenCount += 1;128 hiddenCount += 1;
124 }129 }
130 // Growing the overflow menu is unconditional, or the controls would clip.
131 // Shrinking it has to earn a pixel of slack first: the picker is flexible,
132 // so its natural width is recovered from a truncated label whose
133 // scrollWidth is integral while the rendered box is fractional. The
134 // composer re-measures on every render, so without that margin a host
135 // sitting exactly on a threshold flips a block in and out until React
136 // gives up with "Maximum update depth exceeded".
137 if (previous && hiddenCount < previous.hiddenCount) {
138 if (restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX) {
139 hiddenCount = Math.min(previous.hiddenCount, blockWidths.length);
140 }
141 }
142 const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth);
125 const visible =143 const visible =
126 restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth) <= hostWidth;144 previous && !previous.visible
145 ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX
146 : minimumWidth <= hostWidth;
127 return { hiddenCount, visible };147 return { hiddenCount, visible };
128}148}