| 1 | export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; |
| 2 | export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; |
| 3 | const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; |
| 4 | |
| 5 | export 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 | |
| 16 | export 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 | |
| 26 | export 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 | */ |
| 66 | export 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 | */ |
| 80 | export 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 | */ |
| 102 | export function overlayComposerIsResting(input: { |
| 103 | composerMounted: boolean; |
| 104 | composerReportedResting: boolean; |
| 105 | }): boolean { |
| 106 | return input.composerMounted && input.composerReportedResting; |
| 107 | } |
| 108 | |
| 109 | export function shouldAnimateComposerRestingTransition(input: { |
| 110 | hasCompletedInitialLayout: boolean; |
| 111 | stateChanged: boolean; |
| 112 | hasInterruptedAnimation: boolean; |
| 113 | }): boolean { |
| 114 | return input.hasCompletedInitialLayout && (input.stateChanged || input.hasInterruptedAnimation); |
| 115 | } |
| 116 | |
| 117 | export 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 | |
| 127 | export interface RestingComposerControlsMeasurement { |
| 128 | gap: number; |
| 129 | naturalFixedWidth: number; |
| 130 | minimumFixedWidth: number; |
| 131 | blockWidths: readonly number[]; |
| 132 | overflowWidth: number; |
| 133 | iconOnlyBlockWidths?: readonly number[]; |
| 134 | } |
| 135 | |
| 136 | function 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 | */ |
| 169 | export 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 | */ |
| 184 | const RESTING_CONTROLS_SLACK_PX = 1; |
| 185 | |
| 186 | export 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 | |
| 226 | export 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 | } |