1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic";
4import {
5 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
6 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
7 COMPOSER_RESTING_EXPANSION_MIN_PX,
8 getRestingComposerImagePreviewCounts,
9 overlayComposerIsResting,
10 resolveComposerTimelineInset,
11 resolveScrollToEndClearance,
12 resolveRestingComposerControlsLayout,
13 resolveRestingComposerControlsNaturalWidth,
14 shouldAnimateComposerRestingTransition,
15 shouldUseCompactComposerPrimaryActions,
16 shouldUseCompactComposerFooter,
17 shouldUseRestingComposerLayout,
18} from "./composerFooterLayout";
19
20describe("getRestingComposerImagePreviewCounts", () => {
21 it("shows at most three thumbnails and counts the remainder", () => {
22 expect(getRestingComposerImagePreviewCounts(0)).toEqual({
23 visibleCount: 0,
24 overflowCount: 0,
25 });
26 expect(getRestingComposerImagePreviewCounts(3)).toEqual({
27 visibleCount: 3,
28 overflowCount: 0,
29 });
30 expect(getRestingComposerImagePreviewCounts(7)).toEqual({
31 visibleCount: 3,
32 overflowCount: 4,
33 });
34 });
35});
36
37describe("shouldUseCompactComposerFooter", () => {
38 it("stays expanded without a measured width", () => {
39 expect(shouldUseCompactComposerFooter(null)).toBe(false);
40 });
41
42 it("switches to compact mode below the breakpoint", () => {
43 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX - 1)).toBe(true);
44 });
45
46 it("stays expanded at and above the breakpoint", () => {
47 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX)).toBe(false);
48 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(false);
49 });
50
51 it("uses a higher breakpoint for wide action states", () => {
52 expect(
53 shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1, {
54 hasWideActions: true,
55 }),
56 ).toBe(true);
57 expect(
58 shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, {
59 hasWideActions: true,
60 }),
61 ).toBe(false);
62 });
63});
64
65describe("shouldUseCompactComposerPrimaryActions", () => {
66 it("matches the wide footer breakpoint", () => {
67 expect(
68 shouldUseCompactComposerPrimaryActions(
69 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1,
70 { hasWideActions: true },
71 ),
72 ).toBe(true);
73 expect(
74 shouldUseCompactComposerPrimaryActions(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, {
75 hasWideActions: true,
76 }),
77 ).toBe(false);
78 });
79});
80
81describe("overlayComposerIsResting", () => {
82 it("drops a resting reservation once a status bar replaces the composer", () => {
83 // The composer rested on a scroll, then the thread swapped it for the
84 // subagent bar. The bar's 56px overlay must not keep the resting estimate.
85 const isResting = overlayComposerIsResting({
86 composerMounted: false,
87 composerReportedResting: true,
88 });
89 expect(isResting).toBe(false);
90 expect(resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 56, isResting })).toBe(
91 56,
92 );
93 expect(overlayComposerIsResting({ composerMounted: true, composerReportedResting: true })).toBe(
94 true,
95 );
96 });
97});
98
99describe("resolveComposerTimelineInset", () => {
100 it("follows the expanded overlay height", () => {
101 expect(
102 resolveComposerTimelineInset({ currentInset: 160, overlayHeight: 140, isResting: false }),
103 ).toBe(140);
104 });
105
106 it("keeps a larger expanded reservation while resting", () => {
107 expect(
108 resolveComposerTimelineInset({ currentInset: 200, overlayHeight: 60, isResting: true }),
109 ).toBe(200);
110 });
111
112 it("uses the measured expanded height while the resting strip host is mounting", () => {
113 expect(
114 resolveComposerTimelineInset({ currentInset: 172, overlayHeight: 110, isResting: true }),
115 ).toBe(172);
116 });
117
118 it("keeps timeline padding stable when the model-only strip appears on collapse", () => {
119 const expanded = resolveComposerTimelineInset({
120 currentInset: 0,
121 overlayHeight: 172,
122 isResting: false,
123 });
124 const collapsed = resolveComposerTimelineInset({
125 currentInset: expanded,
126 overlayHeight: 110,
127 isResting: true,
128 restingOnlyHeight: 32,
129 });
130 expect(collapsed).toBe(expanded);
131 expect(
132 resolveComposerTimelineInset({
133 currentInset: collapsed,
134 overlayHeight: 172,
135 isResting: false,
136 }),
137 ).toBe(expanded);
138 });
139
140 it("reserves the empty expansion when no larger height is known", () => {
141 expect(
142 resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 60, isResting: true }),
143 ).toBe(60 + COMPOSER_RESTING_EXPANSION_MIN_PX);
144 });
145});
146
147describe("shouldUseRestingComposerLayout", () => {
148 const resting = {
149 isExistingThread: true,
150 isMobileViewport: false,
151 isScrollCollapsed: true,
152 hasExpandedChrome: false,
153 hasMultilinePrompt: false,
154 timelineOverflows: true,
155 };
156
157 it("uses the resting layout after a timeline scroll", () => {
158 expect(shouldUseRestingComposerLayout(resting)).toBe(true);
159 });
160
161 it("keeps the composer expanded until the timeline is scrolled", () => {
162 expect(shouldUseRestingComposerLayout({ ...resting, isScrollCollapsed: false })).toBe(false);
163 });
164
165 it("keeps the composer expanded while the timeline fits above it", () => {
166 expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false);
167 });
168
169 it("keeps new-thread composers expanded", () => {
170 expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false);
171 });
172
173 it("leaves responsive mobile on its existing collapse path", () => {
174 expect(shouldUseRestingComposerLayout({ ...resting, isMobileViewport: true })).toBe(false);
175 });
176
177 it("keeps drawers and composer-owned menus expanded", () => {
178 expect(shouldUseRestingComposerLayout({ ...resting, hasExpandedChrome: true })).toBe(false);
179 });
180
181 it.each([false, true])(
182 "keeps multiline drafts expanded when scroll collapsed is %s",
183 (isScrollCollapsed) => {
184 expect(
185 shouldUseRestingComposerLayout({
186 ...resting,
187 hasMultilinePrompt: true,
188 isScrollCollapsed,
189 }),
190 ).toBe(false);
191 },
192 );
193});
194
195describe("shouldAnimateComposerRestingTransition", () => {
196 it("does not animate layout measurements that settle during initial mount", () => {
197 expect(
198 shouldAnimateComposerRestingTransition({
199 hasCompletedInitialLayout: false,
200 stateChanged: true,
201 hasInterruptedAnimation: false,
202 }),
203 ).toBe(false);
204 });
205
206 it("animates later resting-state changes and interrupted transitions", () => {
207 expect(
208 shouldAnimateComposerRestingTransition({
209 hasCompletedInitialLayout: true,
210 stateChanged: true,
211 hasInterruptedAnimation: false,
212 }),
213 ).toBe(true);
214 expect(
215 shouldAnimateComposerRestingTransition({
216 hasCompletedInitialLayout: true,
217 stateChanged: false,
218 hasInterruptedAnimation: true,
219 }),
220 ).toBe(true);
221 });
222});
223
224describe("resolveRestingComposerControlsLayout", () => {
225 // Picker 140 natural / 96 minimum, plus a 9px separator. Traits 60,
226 // mode 140, overflow 24, gap 4.
227 const base = {
228 gap: 4,
229 naturalFixedWidth: 149,
230 minimumFixedWidth: 105,
231 blockWidths: [60, 140],
232 overflowWidth: 24,
233 };
234
235 it("shows everything when the host has room", () => {
236 // 149 + 60 + 140 + 4 * 2 = 357
237 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({
238 hiddenCount: 0,
239 visible: true,
240 });
241 });
242
243 it("moves trailing blocks into the overflow menu until the rest fits", () => {
244 // 149 + 60 + 24 + 4 * 2 = 241
245 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 356 })).toEqual({
246 hiddenCount: 1,
247 visible: true,
248 });
249 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({
250 hiddenCount: 1,
251 visible: true,
252 });
253 // 149 + 24 + 4 = 177
254 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({
255 hiddenCount: 2,
256 visible: true,
257 });
258 });
259
260 it("shrinks the picker after moving every trailing block into overflow", () => {
261 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 176 })).toEqual({
262 hiddenCount: 2,
263 visible: true,
264 });
265 // 105 + 24 + 4 = 133
266 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 133 })).toEqual({
267 hiddenCount: 2,
268 visible: true,
269 });
270 });
271
272 it("hides the whole cluster below the picker's minimum readable width", () => {
273 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 132 })).toEqual({
274 hiddenCount: 2,
275 visible: false,
276 });
277 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 0 })).toEqual({
278 hiddenCount: 2,
279 visible: false,
280 });
281 });
282
283 it("uses the same thresholds while shrinking and growing", () => {
284 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 240 })).toEqual({
285 hiddenCount: 2,
286 visible: true,
287 });
288 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241 })).toEqual({
289 hiddenCount: 1,
290 visible: true,
291 });
292 });
293
294 it("supports a single leading control without overflow blocks", () => {
295 expect(
296 resolveRestingComposerControlsLayout({
297 ...base,
298 naturalFixedWidth: 140,
299 minimumFixedWidth: 140,
300 blockWidths: [],
301 overflowWidth: 0,
302 hostWidth: 139,
303 }),
304 ).toEqual({ hiddenCount: 0, visible: false });
305 });
306});
307
308describe("context strip labels and resting composer controls", () => {
309 // Widths captured from a desktop renderer that crashed with React error
310 // 185. The strip is 724px wide. Its expanded labels need 327px, and the
311 // rest of its chrome needs 125px. The composer controls sit in the host
312 // that takes whatever is left.
313 const stripWidth = 724;
314 const labelWidth = 327;
315 const chromeWidth = 125;
316 const measurement = {
317 gap: 4,
318 naturalFixedWidth: 96.3828125 + 5 + 4,
319 minimumFixedWidth: 52 + 5 + 4,
320 blockWidths: [130.6953125, 119.671875],
321 overflowWidth: 28,
322 };
323 const naturalWidth = resolveRestingComposerControlsNaturalWidth(measurement);
324
325 function hostWidth(compact: boolean): number {
326 return stripWidth - chromeWidth - (compact ? 0 : labelWidth);
327 }
328
329 it("keeps the labels compact when the full controls only fit beside compact labels", () => {
330 // Compact labels leave 599px, so the composer shows every block.
331 const layout = resolveRestingComposerControlsLayout({
332 ...measurement,
333 hostWidth: hostWidth(true),
334 });
335 expect(layout).toEqual({ hiddenCount: 0, visible: true });
336
337 // The strip reserves the natural controls width, so expanding the
338 // labels is off the table: 125 + 327 + 364 > 724.
339 const compact = resolveContextStripLabelsCompact({
340 compact: true,
341 neededWidth: chromeWidth + labelWidth + naturalWidth,
342 availableWidth: stripWidth,
343 });
344 expect(compact).toBe(true);
345
346 // The next pass sees the same inputs and lands on the same answer.
347 expect(
348 resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(compact) }),
349 ).toEqual(layout);
350 });
351
352 it("does not settle when the strip only reserves the visible controls", () => {
353 // Regression guard for the alternating layout. Reserving only the
354 // controls left visible after two blocks moved into overflow makes the
355 // strip expand its labels, which shrinks the host below what the full
356 // controls need, which hides the blocks again.
357 const hiddenControlsWidth = 137;
358 const expands = !resolveContextStripLabelsCompact({
359 compact: true,
360 neededWidth: chromeWidth + labelWidth + hiddenControlsWidth,
361 availableWidth: stripWidth,
362 });
363 expect(expands).toBe(true);
364 expect(
365 resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(false) }),
366 ).toEqual({ hiddenCount: 2, visible: true });
367 });
368});
369
370describe("resolveRestingComposerControlsLayout hysteresis", () => {
371 // Same cluster as above: picker 140 natural / 96 minimum plus a 9px
372 // separator, traits 60, mode 140, overflow 24, gap 4.
373 const base = {
374 gap: 4,
375 naturalFixedWidth: 149,
376 minimumFixedWidth: 105,
377 blockWidths: [60, 140],
378 overflowWidth: 24,
379 };
380
381 it("keeps a block in overflow when re-showing it would leave no slack", () => {
382 // 149 + 60 + 140 + 4 * 2 = 357 fills the host exactly.
383 expect(
384 resolveRestingComposerControlsLayout({
385 ...base,
386 hostWidth: 357,
387 previous: { hiddenCount: 1, visible: true },
388 }),
389 ).toEqual({ hiddenCount: 1, visible: true });
390 });
391
392 it("re-shows a block once the host clears the slack margin", () => {
393 expect(
394 resolveRestingComposerControlsLayout({
395 ...base,
396 hostWidth: 358,
397 previous: { hiddenCount: 1, visible: true },
398 }),
399 ).toEqual({ hiddenCount: 0, visible: true });
400 });
401
402 it("restores the blocks that fit when the full cluster has no slack", () => {
403 const partlyRestored = resolveRestingComposerControlsLayout({
404 ...base,
405 hostWidth: 357,
406 previous: { hiddenCount: 2, visible: true },
407 });
408 expect(partlyRestored).toEqual({ hiddenCount: 1, visible: true });
409 expect(
410 resolveRestingComposerControlsLayout({ ...base, hostWidth: 357, previous: partlyRestored }),
411 ).toEqual(partlyRestored);
412 expect(
413 resolveRestingComposerControlsLayout({ ...base, hostWidth: 358, previous: partlyRestored }),
414 ).toEqual({ hiddenCount: 0, visible: true });
415 });
416
417 it("requires slack before partially restoring a cluster", () => {
418 // One inline block, the picker, and overflow need 149 + 60 + 24 + 8 = 241px.
419 const previous = { hiddenCount: 2, visible: true };
420 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 241, previous })).toEqual(
421 previous,
422 );
423 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 242, previous })).toEqual({
424 hiddenCount: 1,
425 visible: true,
426 });
427 });
428
429 it("still resolves from scratch when there is no previous layout", () => {
430 expect(resolveRestingComposerControlsLayout({ ...base, hostWidth: 357 })).toEqual({
431 hiddenCount: 0,
432 visible: true,
433 });
434 });
435
436 it("settles when the measured picker width jitters below a pixel", () => {
437 // Regression guard for React error #185 ("Maximum update depth
438 // exceeded"). The composer re-measures on every render, so the picker's
439 // recovered natural width can land a fraction of a pixel apart between
440 // renders. Without hysteresis that flips hiddenCount forever.
441 let layout = { hiddenCount: 0, visible: true };
442 const settled: string[] = [];
443 for (let index = 0; index < 10; index += 1) {
444 layout = resolveRestingComposerControlsLayout({
445 ...base,
446 // The picker's recovered natural width lands a half pixel apart
447 // between renders.
448 naturalFixedWidth: index % 2 === 0 ? 149 : 149.5,
449 hostWidth: 357,
450 previous: layout,
451 });
452 if (index >= 2) settled.push(`${layout.hiddenCount}:${layout.visible}`);
453 }
454 expect(new Set(settled).size).toBe(1);
455 });
456
457 it("keeps the cluster hidden until its minimum width clears the slack", () => {
458 // 105 + 24 + 4 = 133 is the exact minimum for the hidden cluster.
459 expect(
460 resolveRestingComposerControlsLayout({
461 ...base,
462 hostWidth: 133,
463 previous: { hiddenCount: 2, visible: false },
464 }),
465 ).toEqual({ hiddenCount: 2, visible: false });
466 expect(
467 resolveRestingComposerControlsLayout({
468 ...base,
469 hostWidth: 134,
470 previous: { hiddenCount: 2, visible: false },
471 }),
472 ).toEqual({ hiddenCount: 2, visible: true });
473 });
474});
475
476describe("resolveScrollToEndClearance", () => {
477 it("removes the side tab gap in both composer states while clearing overlapping attachments", () => {
478 for (const overlayHeight of [120, 214]) {
479 const layout = {
480 overlayHeight,
481 mainSurfaceTop: 534,
482 button: { left: 340, right: 460 },
483 attachments: [{ top: 500, left: 600, right: 700 }],
484 };
485 expect(resolveScrollToEndClearance(layout)).toBe(overlayHeight - 34);
486 expect(resolveScrollToEndClearance({ ...layout, attachments: [] })).toBe(overlayHeight);
487 expect(
488 resolveScrollToEndClearance({
489 ...layout,
490 attachments: [...layout.attachments, { top: 500, left: 100, right: 700 }],
491 }),
492 ).toBe(overlayHeight);
493 expect(resolveScrollToEndClearance({ ...layout, button: { left: 590, right: 710 } })).toBe(
494 overlayHeight,
495 );
496 }
497 });
498});
499
500describe("progressive composer controls", () => {
501 const measurement = {
502 gap: 4,
503 naturalFixedWidth: 140,
504 minimumFixedWidth: 80,
505 blockWidths: [80, 140],
506 iconOnlyBlockWidths: [40, 60],
507 overflowWidth: 24,
508 };
509
510 it("keeps labels while they fit and removes trailing labels before controls", () => {
511 for (const [hostWidth, iconOnlyCount, hiddenCount] of [
512 [368, 0, 0],
513 [367, 1, 0],
514 [288, 1, 0],
515 [287, 2, 0],
516 [248, 2, 0],
517 [247, 2, 1],
518 [211, 2, 2],
519 ] as const) {
520 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth })).toEqual({
521 hiddenCount,
522 iconOnlyCount,
523 visible: true,
524 });
525 }
526 });
527
528 it("requires slack to restore labels and controls", () => {
529 for (const [hostWidth, previous, promoted] of [
530 [
531 368,
532 { hiddenCount: 0, iconOnlyCount: 1, visible: true },
533 { hiddenCount: 0, iconOnlyCount: 0, visible: true },
534 ],
535 [
536 288,
537 { hiddenCount: 0, iconOnlyCount: 2, visible: true },
538 { hiddenCount: 0, iconOnlyCount: 1, visible: true },
539 ],
540 [
541 248,
542 { hiddenCount: 1, iconOnlyCount: 2, visible: true },
543 { hiddenCount: 0, iconOnlyCount: 2, visible: true },
544 ],
545 ] as const) {
546 expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth, previous })).toEqual(
547 previous,
548 );
549 expect(
550 resolveRestingComposerControlsLayout({
551 ...measurement,
552 hostWidth: hostWidth + 1,
553 previous,
554 }),
555 ).toEqual(promoted);
556 }
557 });
558
559 it("settles through fractional label-width changes at each threshold", () => {
560 for (const hostWidth of [368, 288, 248]) {
561 let previous = resolveRestingComposerControlsLayout({ ...measurement, hostWidth });
562 for (let index = 0; index < 10; index += 1) {
563 const next = resolveRestingComposerControlsLayout({
564 ...measurement,
565 hostWidth,
566 previous,
567 naturalFixedWidth: 140 + (index % 2) * 0.5,
568 });
569 if (index > 1) expect(next).toEqual(previous);
570 previous = next;
571 }
572 }
573 });
574});