💬 style: Right-Align In-Flight Steer Bubbles to the Message UI (#14814)

* 💬 style: Right-Align In-Flight Steer Bubbles to the Message UI

The chat surface reads as message bubbles now — user turns on the right,
assistant turns on the left — but the in-flight steer bubbles anchored above
the composer were still left-aligned, so a steer sat on the opposite side from
the words the user had just sent, then jumped across on `on_steer_applied`
when the persisted `SteerPart` landed in-thread on the right.

Align the overlay with the user turn it belongs to:

- The bubble stack right-aligns and is constrained to the message column
  (`max-w-3xl`), so the in-flight bubble sits where its applied twin lands
  instead of ~52px further right (the composer runs wider than the message
  column at `xl`).
- The bubble adopts the same theme-token geometry as `SteerPart` and every
  user turn (`rounded-theme-surface rounded-br-theme-control`,
  `px-theme-normal`), replacing the raw `rounded-3xl`/`pl-3 pr-4`. It keeps its
  outline: an in-flight steer is still provisional.
- The controls flank the bubble — overflow menu outboard-left, send-now arrow
  outboard-right — so neither reads as belonging to the other. DOM order
  matches visual order, so focus order stays coherent.

Also drops the thin `bg-border-medium` divider that bound the arrow to its
message: with the arrow now outboard on the far side of the bubble it has
nothing to separate, and `EscalateNowButton` no longer needs its fragment.

* 💬 style: Center the Steer Controls on the Bubble's First Line

The flanking controls read as neither top-aligned nor centered, because their
resting position was an accident of `sticky top-2`: the topmost rail trips the
sticky inset at rest and is shoved 8px below the row top, while every rail
below it clears the inset and stays at the top. So the controls sat 3.8px above
the bubble's centre — and stacked steers did not even agree with each other.

Give each rail a `py-3` band that reproduces the bubble's own first line (its
`py-2.5`, its 1px border, and half the gap between the 24px control and the
taller text line box), and pad the overlay evenly so the topmost rail already
clears the sticky inset instead of being displaced by it.

A 24px control now centres on the first line: measured at 722.0 against the
text's 721.8, versus 718.4 before. Beside a one-line steer that reads as
centred; on a tall one it aligns to the opening line rather than drifting to
the middle, and sticky still carries it while the stack scrolls.
This commit is contained in:
Danny Avila 2026-08-13 23:37:18 -04:00 committed by GitHub
parent da390fa919
commit 7694428ca9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 81 additions and 72 deletions

View file

@ -52,6 +52,17 @@ const STEER_OVERFLOW_TOLERANCE = 8;
* honest unknown outcome; the idempotent arm may still complete server-side. */
const ARM_CONFIRM_TIMEOUT_MS = 10_000;
/** The control rail flanking a bubble. `py-3` reproduces the bubble's own
* first-line band its `py-2.5` padding, its 1px border, and half the gap
* between the 24px control and the taller text line box so a 24px control
* centers on the first line: visually centered beside a one-line steer, and
* aligned to the opening line of a tall one rather than adrift in its middle.
* `sticky` then keeps it in view while a tall steer scrolls past (the stack
* scrolls once it passes 35vh); the matching `pt-2` on the overlay means the
* topmost rail already clears the sticky inset, so it is not shoved down at
* rest while the rails below it which never trip the inset stay put. */
const STEER_CONTROL_RAIL = 'sticky top-2 flex shrink-0 items-center py-3';
type ArmFailure = {
name?: string;
response?: { data?: { code?: string } };
@ -403,10 +414,10 @@ const InFlightSteer = memo(function InFlightSteer({
/* pointer-events-auto: the overlay container disables events so wheeling
* over the gaps reaches the messages behind; each bubble re-enables them
* for its own controls and internal scroll. */
className="group pointer-events-auto flex flex-col items-start gap-1.5"
className="group pointer-events-auto flex flex-col items-end gap-1.5"
>
{(images.length > 0 || others.length > 0) && (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
{others.map((file) => (
<FileContainer
key={file.file_id}
@ -427,14 +438,38 @@ const InFlightSteer = memo(function InFlightSteer({
))}
</div>
)}
{/* items-start so the sticky controls have room to travel — see below. */}
{/* Mirrors the user turn: the whole group hugs the right edge like every
* other message the user wrote. The two controls flank the bubble rather
* than stacking beside each other the overflow menu outboard-left, the
* send-now arrow outboard-right so neither can read as belonging to
* the other, and the pairing repeats cleanly when steers stack.
* items-start so the sticky controls have room to travel see below. */}
<div className="flex max-w-full items-start gap-1.5">
{!sending && (
/* One always-visible affordance: a label-less menu hidden until hover
* is undiscoverable, and edit/queue/cancel all live inside it now, so
* the menu shows at rest on every pointer (matching the always-on
* controls on the queued rows). `sticky` keeps it in view while the
* user scrolls through a tall, expanded steer (the stack scrolls once
* it passes 35vh). */
<div data-testid="steer-controls" className={cn(STEER_CONTROL_RAIL, 'gap-1')}>
<RowMenu
label={localize('com_ui_more_options')}
entries={entries}
preferences={preferences}
buttonRef={optionsButtonRef}
/>
</div>
)}
<div
className={cn(
/* Same bubble geometry as the applied `SteerPart` and every user
* turn, so the words don't reshape when the server injects them. */
'flex min-w-0 items-start gap-2 rounded-theme-surface rounded-br-theme-control',
/* Outlined, not just filled: an in-flight steer is provisional
* the fill alone reads as a settled message. */
'flex min-w-0 items-start gap-2 rounded-3xl border border-border-medium',
'bg-surface-secondary py-2 pl-3 pr-4 text-sm text-text-primary',
'border border-border-medium bg-surface-secondary',
'px-theme-normal py-2.5 text-sm text-text-primary',
sending && 'opacity-70',
)}
>
@ -493,32 +528,15 @@ const InFlightSteer = memo(function InFlightSteer({
)}
</div>
</div>
{!sending && (
/* One always-visible affordance: a label-less menu hidden until hover
* is undiscoverable, and edit/queue/cancel all live inside it now, so
* the menu shows at rest on every pointer (matching the always-on
* controls on the queued rows). `sticky` keeps it in view while the
* user scrolls through a tall, expanded steer (the stack scrolls once
* it passes 35vh). */
<div
data-testid="steer-controls"
className="sticky top-2 flex shrink-0 items-center gap-1"
>
{!preempting && (
<EscalateNowButton
surface="bubble"
messageText={steer.text}
disabled={
interruptPending || steering.pausedOnApproval || !steering.duringRunActive
}
onClick={escalate}
/>
)}
<RowMenu
label={localize('com_ui_more_options')}
entries={entries}
preferences={preferences}
buttonRef={optionsButtonRef}
{!sending && !preempting && (
/* Sticky for the same reason as the menu: a tall, expanded steer must
* never scroll its send-now out of reach. */
<div className={STEER_CONTROL_RAIL}>
<EscalateNowButton
surface="bubble"
messageText={steer.text}
disabled={interruptPending || steering.pausedOnApproval || !steering.duringRunActive}
onClick={escalate}
/>
</div>
)}
@ -622,7 +640,7 @@ const InFlightSteers = memo(function InFlightSteers({
* steer runs to 16k chars and a run takes up to 10 of them; unbounded it
* would cover the whole thread. pointer-events-none lets wheeling over
* the gaps reach those messages (each bubble opts back in). */
className="pointer-events-none absolute inset-x-0 bottom-full flex max-h-[35vh] flex-col items-start gap-2 overflow-y-auto px-2 pb-2"
className="pointer-events-none absolute inset-x-0 bottom-full mx-auto flex max-h-[35vh] max-w-3xl flex-col items-end gap-2 overflow-y-auto p-2"
>
{inFlight.map((steer) => (
<InFlightSteer

View file

@ -234,46 +234,37 @@ export function EscalateNowButton({
}, [disabled, targetId]);
return (
<>
{/* Thin divider binds the arrow to the message on its left, so it can
* never read as part of the bare-glyph menu control beside it the
* send-now belongs to THIS chip, and the pairing repeats cleanly when
* several messages stack. */}
<span aria-hidden="true" className="h-[18px] w-px shrink-0 bg-border-medium" />
<Ariakit.TooltipProvider placement="top" timeout={300}>
<Ariakit.TooltipAnchor
render={
<button
type="button"
aria-label={accessibleLabel}
aria-keyshortcuts={isActive ? ariaKey : undefined}
data-escalate-steer={surface}
data-escalate-steer-active={isActive ? 'true' : undefined}
data-testid={surface === 'queued' ? 'queued-interrupt-now' : 'steer-escalate-now'}
disabled={disabled}
onPointerEnter={() =>
!disabled && updateActiveEscalateTarget('hover', targetId, true)
}
onPointerLeave={() => updateActiveEscalateTarget('hover', targetId, false)}
onFocus={() => !disabled && updateActiveEscalateTarget('focus', targetId, true)}
onBlur={() => updateActiveEscalateTarget('focus', targetId, false)}
onClick={onClick}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-full',
'bg-text-primary text-surface-primary transition-opacity hover:opacity-85',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
'disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35',
)}
>
<ArrowUp className="h-3.5 w-3.5" strokeWidth={2.4} aria-hidden="true" />
</button>
}
/>
<Ariakit.Tooltip className="z-50 rounded-lg bg-surface-tertiary px-2 py-1 text-xs text-text-primary shadow-lg">
{chord && isActive ? `${label} · ${chord}` : label}
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
</>
<Ariakit.TooltipProvider placement="top" timeout={300}>
<Ariakit.TooltipAnchor
render={
<button
type="button"
aria-label={accessibleLabel}
aria-keyshortcuts={isActive ? ariaKey : undefined}
data-escalate-steer={surface}
data-escalate-steer-active={isActive ? 'true' : undefined}
data-testid={surface === 'queued' ? 'queued-interrupt-now' : 'steer-escalate-now'}
disabled={disabled}
onPointerEnter={() => !disabled && updateActiveEscalateTarget('hover', targetId, true)}
onPointerLeave={() => updateActiveEscalateTarget('hover', targetId, false)}
onFocus={() => !disabled && updateActiveEscalateTarget('focus', targetId, true)}
onBlur={() => updateActiveEscalateTarget('focus', targetId, false)}
onClick={onClick}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-full',
'bg-text-primary text-surface-primary transition-opacity hover:opacity-85',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
'disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35',
)}
>
<ArrowUp className="h-3.5 w-3.5" strokeWidth={2.4} aria-hidden="true" />
</button>
}
/>
<Ariakit.Tooltip className="z-50 rounded-lg bg-surface-tertiary px-2 py-1 text-xs text-text-primary shadow-lg">
{chord && isActive ? `${label} · ${chord}` : label}
</Ariakit.Tooltip>
</Ariakit.TooltipProvider>
);
}