export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; export function getRestingComposerImagePreviewCounts(imageCount: number): { visibleCount: number; overflowCount: number; } { const visibleCount = Math.min(imageCount, RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT); return { visibleCount, overflowCount: Math.max(0, imageCount - visibleCount), }; } export function shouldUseCompactComposerFooter( width: number | null, options?: { hasWideActions?: boolean }, ): boolean { const breakpoint = options?.hasWideActions ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX; return width !== null && width < breakpoint; } export function shouldUseRestingComposerLayout(input: { isExistingThread: boolean; isMobileViewport: boolean; isScrollCollapsed: boolean; hasExpandedChrome: boolean; hasMultilinePrompt: boolean; /** Whether the timeline has more content than fits above the composer. */ timelineOverflows: boolean; }): boolean { // Multiline drafts stay readable. Resting only clamps a single prompt // line and overlays its actions; non-image attachment and context // rows keep their natural height above it while image previews move inline. // Banners and the tasks badge dock above the surface, so they are absent // too. Whether the context strip can host the relocated controls is // deliberately absent here: resting reclaims vertical space at every // desktop width, and where the strip is missing or too narrow the controls // simply return when the composer is focused. // // Only a timeline scroll rests the composer: the user asked for it with the // gesture, and it lifts on the next composer interaction. Losing focus never // rests it, so clicking a message, copying output, or selecting text for a // citation leaves the composer where it was. // // Resting exists to give reading space back to the timeline. A thread that // fits above the composer has nothing to reclaim, so it stays expanded and // never shows the collapsed row that a fresh thread would otherwise open on. return ( input.isExistingThread && !input.isMobileViewport && input.timelineOverflows && input.isScrollCollapsed && !input.hasMultilinePrompt && !input.hasExpandedChrome ); } /** * How much taller the empty expanded composer is than its resting row on * desktop widths, from the layout classes in ChatComposer: the body loses * 8px of top padding, the prompt clamps from min-h-17.5 (70px) to 32px, and * the 48px footer leaves flow. */ export const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; /** * The space the timeline reserves at its end for the composer overlay. * * The overlay is measured live, but a resting composer is much shorter than * an expanded one. Reserving only the resting height lets a scroll to the end * land flush against the short composer, and the expansion that follows then * covers the last rows because the timeline never moves for footer growth. * While resting, the reservation keeps the last expanded height, or at least * the resting height plus the empty expansion, so expanding again changes * nothing above the composer. An expanded measurement is authoritative and * may shrink it. */ export function resolveComposerTimelineInset(input: { currentInset: number; overlayHeight: number; isResting: boolean; }): number { return input.isResting ? Math.max(input.currentInset, input.overlayHeight + COMPOSER_RESTING_EXPANSION_MIN_PX) : input.overlayHeight; } export function shouldAnimateComposerRestingTransition(input: { hasCompletedInitialLayout: boolean; stateChanged: boolean; hasInterruptedAnimation: boolean; }): boolean { return input.hasCompletedInitialLayout && (input.stateChanged || input.hasInterruptedAnimation); } export function shouldUseCompactComposerPrimaryActions( width: number | null, options?: { hasWideActions?: boolean }, ): boolean { if (!options?.hasWideActions) { return false; } return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; } export interface RestingComposerControlsMeasurement { gap: number; naturalFixedWidth: number; minimumFixedWidth: number; blockWidths: readonly number[]; overflowWidth: number; } function restingComposerControlsWidth( input: RestingComposerControlsMeasurement, hiddenCount: number, fixedWidth = input.naturalFixedWidth, ): number { const { blockWidths, gap } = input; const visibleCount = blockWidths.length - hiddenCount; return ( fixedWidth + blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) + (hiddenCount > 0 ? input.overflowWidth : 0) + gap * (visibleCount + (hiddenCount > 0 ? 1 : 0)) ); } /** * The width the resting controls take with nothing moved into overflow. * * The context strip reserves this much for the composer before deciding * whether its own labels may expand. Judging against the currently visible * controls instead lets the strip expand into space the composer just gave * up, which shrinks the host, hides the controls again, and repeats. */ export function resolveRestingComposerControlsNaturalWidth( input: RestingComposerControlsMeasurement, ): number { return restingComposerControlsWidth(input, 0); } /** * Decide how many trailing resting control blocks move into the overflow * menu, and whether the cluster can show at all, from natural widths. * * Trailing blocks hide before the model picker shrinks. Once they are all in * the overflow menu, the picker may contract to its minimum readable width; * below that the whole cluster hides rather than clipping. */ const RESTING_CONTROLS_SLACK_PX = 1; export function resolveRestingComposerControlsLayout( input: RestingComposerControlsMeasurement & { hostWidth: number; previous?: { hiddenCount: number; visible: boolean }; }, ): { hiddenCount: number; visible: boolean } { const { blockWidths, hostWidth, previous } = input; let hiddenCount = 0; while ( hiddenCount < blockWidths.length && restingComposerControlsWidth(input, hiddenCount) > hostWidth ) { hiddenCount += 1; } // Growing the overflow menu is unconditional, or the controls would clip. // Shrinking it has to earn a pixel of slack first: the picker is flexible, // so its natural width is recovered from a truncated label whose // scrollWidth is integral while the rendered box is fractional. The // composer re-measures on every render, so without that margin a host // sitting exactly on a threshold flips a block in and out until React // gives up with "Maximum update depth exceeded". if (previous) { const previousHiddenCount = Math.min(previous.hiddenCount, blockWidths.length); while ( hiddenCount < previousHiddenCount && restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX ) { hiddenCount += 1; } } const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth); const visible = previous && !previous.visible ? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX : minimumWidth <= hostWidth; return { hiddenCount, visible }; } export function resolveScrollToEndClearance(input: { overlayHeight: number; mainSurfaceTop: number; button: { left: number; right: number }; attachments: ReadonlyArray<{ top: number; left: number; right: number }>; }): number { let contentTop = input.mainSurfaceTop; let top = contentTop; for (const attachment of input.attachments) { contentTop = Math.min(contentTop, attachment.top); if (attachment.left < input.button.right && attachment.right > input.button.left) { top = Math.min(top, attachment.top); } } return Math.ceil(input.overlayHeight - (top - contentTop)); }