Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions desktop/src/renderer/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -132,6 +140,7 @@ export default function ChatView({ sessionId, visible, resumeInfo, cwd, gamePane

const bottomRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(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.
Expand Down Expand Up @@ -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<HTMLElement>('.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
Expand Down Expand Up @@ -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,
Expand All @@ -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',
}}
>
Expand Down
50 changes: 42 additions & 8 deletions desktop/src/renderer/components/SessionStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -777,19 +796,34 @@ export default function SessionStrip({
title={s.name}
>
<SessionDot color={color} isActive={isActive} />
{/* 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. */}
<span
className={`text-xs font-medium text-fg-2 whitespace-nowrap overflow-hidden text-ellipsis ${isActive ? 'min-w-0' : ''}`}
style={{
// Active pill flex-shrinks so ellipsis kicks in when the
// strip is narrower than the full name (no hard cap).
maxWidth: showName
? (isActive ? undefined : 120)
: 0,
display: 'grid',
// min-width:0 must sit on the WRAPPER now that it is the
// flex child — without it the active pill can't shrink and
// its label never ellipsizes on a narrow strip.
minWidth: 0,
gridTemplateColumns: showName ? '1fr' : '0fr',
opacity: showName ? 1 : 0,
transition: pack.expanded.has(s.id) ? 'none' : 'max-width 200ms ease, opacity 150ms ease',
transition: (pack.expanded.has(s.id) && !activeSwap)
? 'none'
: 'grid-template-columns 200ms ease, opacity 150ms ease',
}}
>
{s.name}
<span
className="text-xs font-medium text-fg-2 whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
// Non-active pills keep the 120px hover-reveal cap; the
// active pill stays uncapped (see wrapper comment).
style={{ maxWidth: isActive ? undefined : 120 }}
>
{s.name}
</span>
</span>
{/* Native-runtime badge — marks a YouCoded harness session and
which preset it runs as. Only when the name is showing so it
Expand Down
51 changes: 51 additions & 0 deletions desktop/src/renderer/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> 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; }
Expand Down
Loading