Skip to content

feat(ui): animate session switching — pill reveal + bubble pop - #192

Draft
itsdestin wants to merge 1 commit into
masterfrom
feat/session-switch-animation
Draft

feat(ui): animate session switching — pill reveal + bubble pop#192
itsdestin wants to merge 1 commit into
masterfrom
feat/session-switch-animation

Conversation

@itsdestin

Copy link
Copy Markdown
Owner

DRAFT — do not merge. This needs another session of refinement first. See
"Outstanding before merge" below. It is opened now to capture the work and the
reasoning while it's fresh, not because it's ready.

Switching conversations was two hard cuts: the active pill's name snapped open,
and the transcript was replaced instantly. This adds motion to both.

The pill had two independent reasons it couldn't animate

Worth recording, because "just add a transition" was the wrong diagnosis — one
already existed and was being suppressed.

  1. maxWidth was undefined when the pill was active (SessionStrip.tsx:785-787),
    leaving no numeric pair for the browser to interpolate, so width snapped to
    intrinsic.
  2. transition was hard-set to 'none' for every pack-expanded pill
    (:789) — and packSessions puts the active pill in expanded
    unconditionally (pack-sessions.ts:53). The pill you just clicked was
    precisely the one with animation switched off.

Fixed by animating grid-template-columns: 0fr → 1fr, which interpolates to the
label's intrinsic width — max-width can't do that without imposing a hard cap,
and the active pill is meant to be uncapped so it flex-shrinks and ellipsizes only
when the strip itself is narrow. Non-active hover reveals keep their 120px cap.
The 'none' is preserved for repack churn and overridden only inside a short
window armed by an active-id change.

Bubbles

bubble-switch-out / bubble-switch-in keyframes on the same overshoot curve the
pills already use (cubic-bezier(0.34, 1.56, 0.64, 1)), 30ms stagger capped at 8.

Applied only to .timeline-entry.in-view — reusing the class the bubble
IntersectionObserver already maintains for the glass optimization
(ChatView.tsx:273-291). Off-screen bubbles are never animated, so cost stays flat
regardless of transcript length. This was a hard requirement from the design review.

Panes stay mounted and stacked, so the outgoing one is held for 120ms to finish its
exit while the incoming bubbles wait out an equal animation-delay with both
fill — sequential, not a crossfade.

Both keyframes end on transform: none rather than scale(1), matching the
row-fade-in convention (globals.css:1168-1171) that avoids leaving a persistent
compositing layer under fill-mode: both.

Scope

  • Gated on prefers-reduced-motion and the app's reducedEffects setting.
    The session strip had no reduced-motion gate at all before this.
  • Desktop only, by nature. Android's forceSingle filters to one pill keyed by
    session id, so it remounts on every switch — no before-state to transition from,
    and no expansion to show, since its single pill is always named. Not faked.
  • Buddy window's SessionPill is a plain dropdown with always-visible labels; the
    pill fix does not apply there and it is untouched.

Outstanding before merge

  1. The 120ms exit needs a real-use verdict. It is the cost of a genuine
    "disappear" instead of a cut, and it delays the incoming conversation by 120ms.
    Eyeballed in a dev instance and it reads fine, but it hasn't been lived with.
    Cutting the exit half is a one-line change if it grates.
  2. **It also fires on Ctrl+** (chat↔terminal). A ChatView's sessionIdnever changes, sovisible` is the only edge available and the view toggle is
    indistinguishable from a session switch at this layer. Suppressing it needs a
    different signal threaded down from App.
  3. No test coverage. Nothing in the suite renders SessionStrip or ChatView
    (only packSessions' 9 pure cases are pinned), so nothing here is guarded
    against regression. Worth deciding whether the .in-view-only rule — the load
    bearing perf property — deserves a pinning test.
  4. Interaction with drag-reorder is unverified. The pill's drag path sets its
    own transition and suppressClick; the activeSwap window has not been
    tested mid-drag.
  5. Not verified on a narrow viewport or over remote access, where the strip
    packs differently.

Verification

tsc --noEmit clean; full suite green (2784 passed, 1 skipped file). Eyeballed in a
dev instance on run-dev.sh across themes. Interactive verification was handed to
Destin per the workspace rule rather than scripted.

🤖 Generated with Claude Code

Switching conversations was two hard cuts: the active pill's name snapped
open, and the transcript was replaced instantly.

Session pill (SessionStrip.tsx) — the label had TWO independent reasons it
could not animate. maxWidth was `undefined` when active, leaving no numeric
pair to interpolate; and the transition was hard-disabled for every
pack-expanded pill, which packSessions guarantees the active pill always is.
The pill you just clicked was exactly the one with animation switched off.
Fixed by animating grid-template-columns 0fr->1fr (interpolates to intrinsic
width, so the active pill keeps its uncapped shrink-and-ellipsis) and by
arming a short window on active-id change so the existing 'none' still
suppresses repack churn everywhere else.

Chat bubbles (ChatView.tsx, globals.css) — bubbles pop away on exit and pop
back in on enter, on the same overshoot curve the pills already use. Only
entries carrying .in-view animate, reusing the class the bubble
IntersectionObserver already maintains, so off-screen bubbles cost nothing
and the work stays flat on a long transcript. Panes are mounted and stacked,
so the outgoing one is held for 120ms to finish its exit while the incoming
bubbles wait out an equal animation-delay — sequential, not a crossfade.

Gated on both prefers-reduced-motion and the app's reducedEffects setting.
Desktop-only by nature: Android's forceSingle remounts its single pill, so
there is no before-state to transition from and no expansion to show.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant