| author | |
| committer | |
| log | 0ba06a122bc73f705bfc18875366b4eeb1160992 |
| tree | eb29343d24aec4b524f6fa646fd211bf5e973dea |
| parent | f54ab901fa77f76eeb1e1cdfa103b9ed6f5ee4ca |
| signature | Signed by PGP key B5690EEEBB952194 |
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; |
| 878 | 878 | ||
| 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.visible | 885 | return next.hiddenCount === current.hiddenCount && next.visible === current.visible |
| 882 | ? current | 886 | ? 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 | |||
| 304 | describe("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 | */ |
| 114 | const RESTING_CONTROLS_SLACK_PX = 1; | ||
| 115 | |||
| 114 | export function resolveRestingComposerControlsLayout( | 116 | export 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 | } |