From f50b07b6f9190c2222f94c8b2f4fcea2a2fcce41 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 27 Jul 2026 04:29:18 -0700 Subject: [PATCH] fix(web-ui): keep the composer card synced when the input wraps At the exact point where the session input wraps to a second line, the capsule -> multi-line flip left the card and its controls out of sync. - The box transitioned `max-height` 44px -> 500px while its `height` stays `auto`, so mid-transition the painted border box was clamped below its own content. `overflow` must stay visible for the popovers, so the text and the whole model/mic/send row painted outside the rounded border. Measured on the same rule set: the action row sat 55px below the painted box bottom at the start of the transition. Only `min-height` is animated now - it can never fall below the content, so the collapse still animates and the card never clips. - `__actions-left` / `__actions-right` re-ran `bitfun-stacked-reveal` on every flip (capsule mode nulled it out, so it never played on mount). With `both` fill the groups were pinned at `opacity: 0` through the 0.17s / 0.22s delay and finished sliding ~0.45s after the box had already resized. - The ResizeObserver reacted to the animating box height, and each callback cloned the whole composer into the document to re-measure the capsule width (two forced layouts per frame of the transition). It now ignores entries whose width is unchanged. Trade-off: growth is no longer animated. A CSS height animation from small to large always clamps the box below its content; smooth growth would need `overflow: hidden` during the transition, which would clip the mention and model popovers. The radius, padding, shadow and border-color transitions still carry the shape morph. --- .../src/flow_chat/components/ChatInput.scss | 29 +++++++++---------- .../src/flow_chat/components/ChatInput.tsx | 20 ++++++++++++- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index a904960f97..39f05f5bac 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -374,7 +374,6 @@ // Match the 7px box padding so the round Plus button keeps equal // clearance on all four sides (top/bottom/left/right) margin-right: 7px; - animation: none; // Enlarge the Plus button to match the capsule height .bitfun-chat-input__agent-boost-add { @@ -418,7 +417,6 @@ align-items: center; gap: $size-gap-1; padding: 0; - animation: none; // Enlarge send/stop button to match the capsule height .bitfun-chat-input__send-button { @@ -631,11 +629,16 @@ background: var(--color-bg-scene); backdrop-filter: blur(16px) saturate(1.2); -webkit-backdrop-filter: blur(16px) saturate(1.2); + // `max-height` must never animate: the box height is `auto`, so an + // interpolating `max-height` clamps the border box below its own content + // while `overflow` stays visible (popovers need it) — the text and the + // action row then paint *outside* the rounded border for the length of the + // transition. Only `min-height` is animated: it can never fall below the + // content, so it smooths the collapse without ever clipping the card. transition: border-radius 0.32s cubic-bezier(0.4, 0, 0.2, 1), padding 0.32s cubic-bezier(0.4, 0, 0.2, 1), min-height 0.32s cubic-bezier(0.4, 0, 0.2, 1), - max-height 0.32s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.32s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.32s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: @@ -1721,12 +1724,18 @@ padding: 0 0 $size-gap-2; } + // Keep the action groups free of entrance animations. The composer mounts in + // capsule mode, which used to null the animation out, so a reveal here never + // played on mount — it only restarted on every capsule → multi-line flip, and + // its `both` fill held the group at `opacity: 0` through the delay. That left + // the plus button and the model/mic/send cluster blank for ~0.2s and still + // sliding ~0.45s after the box had already resized, which reads as the card + // and its controls desyncing on a single keystroke at the wrap boundary. &__actions-left { display: flex; align-items: center; gap: $size-gap-1; min-width: 0; - animation: bitfun-stacked-reveal 0.28s cubic-bezier(0.4, 0, 0.2, 1) 0.17s both; } &__model-usage-group { @@ -1741,7 +1750,6 @@ align-items: center; gap: $size-gap-1; height: 100%; - animation: bitfun-stacked-reveal 0.24s cubic-bezier(0.4, 0, 0.2, 1) 0.22s both; } &__split-actions { @@ -2119,14 +2127,3 @@ transform: translateY(0); } } - -@keyframes bitfun-stacked-reveal { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 823fbdb510..b18f006be3 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -753,7 +753,25 @@ export const ChatInput: React.FC = ({ } let rafId: number | null = null; - const observer = new ResizeObserver(() => { + // Only width feeds the capsule measurement, and re-measuring clones the whole + // composer into the document (two forced layouts). The box height animates on + // every capsule ↔ multi-line flip, so reacting to height would run that clone + // once per frame of the transition — exactly while the user is typing at the + // wrap boundary. Ignore entries whose width is unchanged. + const lastObservedWidths = new WeakMap(); + const observer = new ResizeObserver(entries => { + let widthChanged = false; + for (const entry of entries) { + const width = entry.contentRect.width; + const previousWidth = lastObservedWidths.get(entry.target); + if (previousWidth === undefined || Math.abs(previousWidth - width) >= 0.5) { + widthChanged = true; + } + lastObservedWidths.set(entry.target, width); + } + if (!widthChanged) { + return; + } if (rafId !== null) { cancelAnimationFrame(rafId); }