1export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
2export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;
3const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3;
4
5export function getRestingComposerImagePreviewCounts(imageCount: number): {
6 visibleCount: number;
7 overflowCount: number;
8} {
9 const visibleCount = Math.min(imageCount, RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT);
10 return {
11 visibleCount,
12 overflowCount: Math.max(0, imageCount - visibleCount),
13 };
14}
15
16export function shouldUseCompactComposerFooter(
17 width: number | null,
18 options?: { hasWideActions?: boolean },
19): boolean {
20 const breakpoint = options?.hasWideActions
21 ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX
22 : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX;
23 return width !== null && width < breakpoint;
24}
25
26export function shouldUseRestingComposerLayout(input: {
27 isExistingThread: boolean;
28 isMobileViewport: boolean;
29 isScrollCollapsed: boolean;
30 hasExpandedChrome: boolean;
31 hasMultilinePrompt: boolean;
32 /** Whether the timeline has more content than fits above the composer. */
33 timelineOverflows: boolean;
34}): boolean {
35 // Multiline drafts stay readable. Resting only clamps a single prompt
36 // line and overlays its actions; non-image attachment and context
37 // rows keep their natural height above it while image previews move inline.
38 // Banners and the tasks badge dock above the surface, so they are absent
39 // too. The context strip is optional: collapsed controls use it when
40 // present and otherwise occupy a compact row inside the composer.
41 //
42 // Only a timeline scroll rests the composer: the user asked for it with the
43 // gesture, and it lifts on the next composer interaction. Losing focus never
44 // rests it, so clicking a message, copying output, or selecting text for a
45 // citation leaves the composer where it was.
46 //
47 // Resting exists to give reading space back to the timeline. A thread that
48 // fits above the composer has nothing to reclaim, so it stays expanded and
49 // never shows the collapsed row that a fresh thread would otherwise open on.
50 return (
51 input.isExistingThread &&
52 !input.isMobileViewport &&
53 input.timelineOverflows &&
54 input.isScrollCollapsed &&
55 !input.hasMultilinePrompt &&
56 !input.hasExpandedChrome
57 );
58}
59
60/**
61 * How much taller the empty expanded composer is than its resting row on
62 * desktop widths, from the layout classes in ChatComposer: the body loses
63 * 8px of top padding, the prompt clamps from min-h-17.5 (70px) to 32px, and
64 * the 48px footer leaves flow.
65 */
66export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94;
67
68/**
69 * The space the timeline reserves at its end for the composer overlay.
70 *
71 * The overlay is measured live, but a resting composer is much shorter than
72 * an expanded one. Reserving only the resting height lets a scroll to the end
73 * land flush against the short composer, and the expansion that follows then
74 * covers the last rows because the timeline never moves for footer growth.
75 * While resting, keep the measured expanded height. Estimate the empty
76 * expansion only before that measurement exists: strip mounting can otherwise
77 * inflate the estimate mid-transition and move the timeline. An expanded
78 * measurement is authoritative and may shrink the reservation.
79 */
80export function resolveComposerTimelineInset(input: {
81 currentInset: number;
82 overlayHeight: number;
83 isResting: boolean;
84 restingOnlyHeight?: number;
85}): number {
86 return input.isResting
87 ? Math.max(
88 input.currentInset,
89 input.overlayHeight +
90 (input.currentInset === 0
91 ? COMPOSER_RESTING_EXPANSION_MIN_PX - (input.restingOnlyHeight ?? 0)
92 : 0),
93 )
94 : input.overlayHeight;
95}
96
97/**
98 * Whether the overlay's composer is resting. Only a mounted composer can be:
99 * a status bar in its place (a native subagent thread) never is, even if the
100 * composer it replaced last reported resting.
101 */
102export function overlayComposerIsResting(input: {
103 composerMounted: boolean;
104 composerReportedResting: boolean;
105}): boolean {
106 return input.composerMounted && input.composerReportedResting;
107}
108
109export function shouldAnimateComposerRestingTransition(input: {
110 hasCompletedInitialLayout: boolean;
111 stateChanged: boolean;
112 hasInterruptedAnimation: boolean;
113}): boolean {
114 return input.hasCompletedInitialLayout && (input.stateChanged || input.hasInterruptedAnimation);
115}
116
117export function shouldUseCompactComposerPrimaryActions(
118 width: number | null,
119 options?: { hasWideActions?: boolean },
120): boolean {
121 if (!options?.hasWideActions) {
122 return false;
123 }
124 return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;
125}
126
127export interface RestingComposerControlsMeasurement {
128 gap: number;
129 naturalFixedWidth: number;
130 minimumFixedWidth: number;
131 blockWidths: readonly number[];
132 overflowWidth: number;
133 iconOnlyBlockWidths?: readonly number[];
134}
135
136function restingComposerControlsWidth(
137 input: RestingComposerControlsMeasurement,
138 hiddenCount: number,
139 fixedWidth = input.naturalFixedWidth,
140 iconOnlyCount = 0,
141): number {
142 const { blockWidths, gap } = input;
143 const visibleCount = blockWidths.length - hiddenCount;
144 return (
145 fixedWidth +
146 blockWidths
147 .slice(0, visibleCount)
148 .reduce(
149 (sum, width, index) =>
150 sum +
151 (index >= blockWidths.length - iconOnlyCount
152 ? (input.iconOnlyBlockWidths?.[index] ?? width)
153 : width),
154 0,
155 ) +
156 (hiddenCount > 0 ? input.overflowWidth : 0) +
157 gap * (visibleCount + (hiddenCount > 0 ? 1 : 0))
158 );
159}
160
161/**
162 * The width the resting controls take with nothing moved into overflow.
163 *
164 * The context strip reserves this much for the composer before deciding
165 * whether its own labels may expand. Judging against the currently visible
166 * controls instead lets the strip expand into space the composer just gave
167 * up, which shrinks the host, hides the controls again, and repeats.
168 */
169export function resolveRestingComposerControlsNaturalWidth(
170 input: RestingComposerControlsMeasurement,
171): number {
172 return restingComposerControlsWidth(input, 0);
173}
174
175/**
176 * Fit footer controls using natural widths: remove trailing labels first,
177 * then move trailing blocks into overflow. Resting and expanded share this
178 * decision, including the slack needed to safely restore controls.
179 *
180 * Trailing blocks compact before the model picker shrinks. Once they are all in
181 * the overflow menu, the picker may contract to its minimum readable width;
182 * below that the whole cluster hides rather than clipping.
183 */
184const RESTING_CONTROLS_SLACK_PX = 1;
185
186export function resolveRestingComposerControlsLayout(
187 input: RestingComposerControlsMeasurement & {
188 hostWidth: number;
189 previous?: { hiddenCount: number; iconOnlyCount?: number; visible: boolean };
190 },
191): { hiddenCount: number; iconOnlyCount?: number; visible: boolean } {
192 const { blockWidths, hostWidth, previous } = input;
193 const iconSteps = input.iconOnlyBlockWidths ? blockWidths.length : 0;
194 const previousStep = previous
195 ? previous.hiddenCount > 0
196 ? iconSteps + Math.min(previous.hiddenCount, blockWidths.length)
197 : Math.min(previous.iconOnlyCount ?? 0, iconSteps)
198 : 0;
199 let step = 0;
200 const widthAtStep = (candidate: number, fixedWidth = input.naturalFixedWidth) =>
201 restingComposerControlsWidth(
202 input,
203 Math.max(0, candidate - iconSteps),
204 fixedWidth,
205 Math.min(candidate, iconSteps),
206 );
207 // Promotions need a pixel of slack: recovering a flexible picker's natural
208 // width can jitter by a fraction of a pixel across renders. Demotions are
209 // immediate so a threshold cannot clip or flip React between layouts.
210 while (
211 step < iconSteps + blockWidths.length &&
212 widthAtStep(step) > hostWidth - (step < previousStep ? RESTING_CONTROLS_SLACK_PX : 0)
213 ) {
214 step += 1;
215 }
216 const hiddenCount = Math.max(0, step - iconSteps);
217 const iconOnlyCount = Math.min(step, iconSteps);
218 const minimumWidth = widthAtStep(step, input.minimumFixedWidth);
219 const visible =
220 previous && !previous.visible
221 ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX
222 : minimumWidth <= hostWidth;
223 return { hiddenCount, ...(input.iconOnlyBlockWidths ? { iconOnlyCount } : {}), visible };
224}
225
226export function resolveScrollToEndClearance(input: {
227 overlayHeight: number;
228 mainSurfaceTop: number;
229 button: { left: number; right: number };
230 attachments: ReadonlyArray<{ top: number; left: number; right: number }>;
231}): number {
232 let contentTop = input.mainSurfaceTop;
233 let top = contentTop;
234 for (const attachment of input.attachments) {
235 contentTop = Math.min(contentTop, attachment.top);
236 if (attachment.left < input.button.right && attachment.right > input.button.left) {
237 top = Math.min(top, attachment.top);
238 }
239 }
240 return Math.ceil(input.overlayHeight - (top - contentTop));
241}