diff --git a/desktop/src/renderer/components/ChatView.tsx b/desktop/src/renderer/components/ChatView.tsx index 878b0b427..879b1c5ff 100644 --- a/desktop/src/renderer/components/ChatView.tsx +++ b/desktop/src/renderer/components/ChatView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useChatState, useChatDispatch } from '../state/chat-context'; import { HISTORY_EXPAND_PROMPT_ID } from '../state/chat-types'; import UserMessage from './UserMessage'; @@ -20,6 +20,14 @@ import { useActiveProject } from '../hooks/useActiveProject'; import { assistantName } from '../utils/assistant-name'; import { ContentFindBar } from './ContentFindBar'; +// Session-switch motion timings. Must stay in step with the `bubble-switch-*` +// keyframes and the `.session-entering` delay in globals.css. +const SWITCH_EXIT_MS = 120; +const SWITCH_STAGGER_MS = 30; +const SWITCH_STAGGER_CAP = 8; +// Longest possible finish: exit hand-off + capped stagger + pop-in duration. +const SWITCH_ENTER_MS = SWITCH_EXIT_MS + SWITCH_STAGGER_CAP * SWITCH_STAGGER_MS + 260; + interface Props { sessionId: string; visible: boolean; @@ -85,7 +93,7 @@ function HistoryExpandButton({ sessionId, resumeInfo }: { export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane, provider, onOpenProviderSettings }: Props) { const state = useChatState(sessionId); const dispatch = useChatDispatch(); - const { showTimestamps } = useTheme(); + const { showTimestamps, reducedEffects } = useTheme(); // Artifact drawer state — read from ArtifactContext so ChatView reacts to // the drawer toggle without needing a prop threaded down from App.tsx. const { state: artifactState, dispatch: artifactDispatch } = useArtifact(); @@ -132,6 +140,7 @@ export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane const bottomRef = useRef(null); const scrollContainerRef = useRef(null); + const rootRef = useRef(null); const [atBottom, setAtBottom] = useState(true); // Ctrl+F find-over-chat-history. Searches the message timeline (contentRef) // via the same CSS-Highlight ContentFindBar the artifact viewer uses. @@ -200,6 +209,46 @@ export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane return () => cancelAnimationFrame(raf); }, [visible, scrollToBottom]); + // ── Session-switch motion ──────────────────────────────────────────────── + // Bubbles pop away on the way out and pop back in on the way in. Every + // session's pane stays MOUNTED and stacked in the same box (see the + // visibility/opacity note on the root div below), so animating both panes at + // once would read as a crossfade. Instead the outgoing pane is held visible + // for SWITCH_EXIT_MS while its bubbles shrink away, and the incoming pane's + // bubbles carry an equal animation-delay with `both` fill — they sit at + // opacity 0 until the exit has finished, so the two never dissolve through + // each other. `visible` is the only edge available here: a ChatView's + // sessionId never changes, so this also fires on the Ctrl+` chat/terminal + // toggle, which wants the same entrance. + const [switchPhase, setSwitchPhase] = useState<'idle' | 'entering' | 'leaving'>('idle'); + const prevVisibleRef = useRef(visible); + useLayoutEffect(() => { + const was = prevVisibleRef.current; + prevVisibleRef.current = visible; + if (was === visible) return; + if (reducedEffects) { setSwitchPhase('idle'); return; } + // Only entries the IntersectionObserver has marked .in-view get a stagger + // index, so off-screen bubbles are never animated at all — that keeps the + // cost flat on a long transcript instead of scaling with its length. + // The index is capped so a tall window can't grow a long trailing ripple. + const root = rootRef.current; + if (root) { + let i = 0; + root.querySelectorAll('.timeline-entry.in-view').forEach((el) => { + el.style.setProperty('--switch-i', String(Math.min(i++, SWITCH_STAGGER_CAP))); + }); + } + setSwitchPhase(visible ? 'entering' : 'leaving'); + const t = setTimeout( + () => setSwitchPhase('idle'), + visible ? SWITCH_ENTER_MS : SWITCH_EXIT_MS, + ); + return () => clearTimeout(t); + }, [visible, reducedEffects]); + + // The outgoing pane must stay painted until its exit animation finishes. + const paneShown = visible || switchPhase === 'leaving'; + // Fix: input bar height can differ between sessions (drafts, multi-line), // so --bottom-chrome-height changes right after tab switch. App's ResizeObserver // updates the CSS var asynchronously, which grows .chat-scroll's padding-bottom @@ -540,8 +589,14 @@ export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane // reports). Using visibility+opacity+pointer-events keeps the layout box // stable across toggles — no reflow, no flash, and focus/IME survive. // `inert` removes hidden subtree from tab order + a11y tree. + ref={rootRef} + // `inert`/`aria-hidden`/`pointerEvents` follow `visible`, NOT `paneShown`: + // the outgoing pane lingers a few frames to finish its exit animation and + // must be non-interactive and out of the a11y tree for all of them. inert={!visible} aria-hidden={visible ? undefined : true} + className={switchPhase === 'entering' ? 'session-entering' + : switchPhase === 'leaving' ? 'session-leaving' : undefined} style={{ position: 'absolute', top: 0, @@ -550,8 +605,8 @@ export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane bottom: 0, display: 'flex', flexDirection: 'column', - visibility: visible ? 'visible' : 'hidden', - opacity: visible ? 1 : 0, + visibility: paneShown ? 'visible' : 'hidden', + opacity: paneShown ? 1 : 0, pointerEvents: visible ? 'auto' : 'none', }} > diff --git a/desktop/src/renderer/components/SessionStrip.tsx b/desktop/src/renderer/components/SessionStrip.tsx index 66c07c6a8..5165fbf66 100644 --- a/desktop/src/renderer/components/SessionStrip.tsx +++ b/desktop/src/renderer/components/SessionStrip.tsx @@ -675,6 +675,25 @@ export default function SessionStrip({ return Math.ceil(textWidth + 28); }, []); + // Fix: the active pill's label used to snap open instead of rolling out like + // the hover reveal does. Two independent causes — maxWidth was `undefined` + // when active (no numeric pair for the browser to interpolate), and the + // transition was hard-disabled for every pack-expanded pill, which + // packSessions guarantees the active pill always is (pack-sessions.ts:53). + // So the pill you just clicked was precisely the one with animation off. + // That 'none' still earns its keep for suppressing repack churn, so instead + // of dropping it we arm a short window on an active-id change and let the + // window win — packing changes outside that window stay instant. + const [activeSwap, setActiveSwap] = useState(false); + const prevActiveRef = useRef(activeSessionId); + useEffect(() => { + if (prevActiveRef.current === activeSessionId) return; + prevActiveRef.current = activeSessionId; + setActiveSwap(true); + const t = setTimeout(() => setActiveSwap(false), 260); + return () => clearTimeout(t); + }, [activeSessionId]); + const repack = useCallback(() => { const bar = pillBarRef.current; if (!bar) return; @@ -777,19 +796,34 @@ export default function SessionStrip({ title={s.name} > + {/* Grid wrapper exists purely to make the reveal animatable: + grid-template-columns 0fr→1fr interpolates to the label's + INTRINSIC width, which max-width cannot do without imposing + a hard cap. That matters because the active pill is meant to + have no cap — it flex-shrinks so ellipsis kicks in only when + the strip itself is too narrow. */} - {s.name} + + {s.name} + {/* Native-runtime badge — marks a YouCoded harness session and which preset it runs as. Only when the name is showing so it diff --git a/desktop/src/renderer/styles/globals.css b/desktop/src/renderer/styles/globals.css index bc6fe17c6..3d792f7cf 100644 --- a/desktop/src/renderer/styles/globals.css +++ b/desktop/src/renderer/styles/globals.css @@ -1174,6 +1174,57 @@ body[data-mode="buddy-chat"] #theme-bg { to { opacity: 1; transform: none; } } +/* Session switch — chat bubble exit + entrance. + Both keyframes end on `transform: none` (not scale(1)/translateY(0)) so + fill-mode: both doesn't leave a persistent compositing layer on every + timeline entry — same constraint as row-fade-in above. Bubbles carry no + backdrop-filter of their own (the glass cascade targets .in-view .bg-inset), + so transform DURING the animation is safe. + + Applied by ChatView's switchPhase, and ONLY to .in-view entries — the class + the bubble IntersectionObserver already maintains for the glass optimization. + Off-screen bubbles are never animated, so the cost is flat regardless of how + long the transcript is. --switch-i is the per-entry stagger index, assigned + in ChatView and capped there. */ +@keyframes bubble-switch-out { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: scale(0.96) translateY(-4px); } +} + +@keyframes bubble-switch-in { + from { opacity: 0; transform: scale(0.9) translateY(10px); } + to { opacity: 1; transform: none; } +} + +.session-leaving .timeline-entry.in-view { + animation: bubble-switch-out 120ms ease-in both; +} + +/* The 120ms delay hands off from the outgoing pane's exit. Panes are stacked + and both are painted during that window, so `both` fill is load-bearing: + it holds the incoming bubbles at opacity 0 until the outgoing ones have + gone, which is what keeps this a sequential swap rather than a crossfade. */ +.session-entering .timeline-entry.in-view { + animation: bubble-switch-in 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both; + animation-delay: calc(120ms + var(--switch-i, 0) * 30ms); +} + +@media (prefers-reduced-motion: reduce) { + .session-leaving .timeline-entry.in-view, + .session-entering .timeline-entry.in-view { animation: none; } +} + +/* The app's own reducedEffects setting, mirrored onto by + theme-engine.ts::applyThemeToDom. ChatView already skips the switch phase in + JS when it's on; this covers the session strip, whose pill reveal is an + INLINE transition and so needs !important to be reachable from a stylesheet. */ +@media (prefers-reduced-motion: reduce) { + .session-strip * { transition: none !important; } +} +[data-reduced-effects] .session-strip * { transition: none !important; } +[data-reduced-effects] .session-leaving .timeline-entry.in-view, +[data-reduced-effects] .session-entering .timeline-entry.in-view { animation: none; } + @keyframes challenge-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; }