[eric] chat: disclosure motion copied from the best OSS, 200ms Apple-curve height + blur-in, rotating chevrons, running labels shimmer

This commit is contained in:
ciregenz
2026-08-04 08:28:59 -07:00
parent b3377ce712
commit ada4f4a41e
4 changed files with 71 additions and 17 deletions
@@ -4,7 +4,7 @@ import Typography from '@mui/material/Typography';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx } from './toolRowMotion';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import BlockIcon from '@mui/icons-material/Block';
import SearchIcon from '@mui/icons-material/Search';
@@ -101,6 +101,7 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
fontWeight: isPending ? 600 : 500,
flexShrink: 0,
transition: 'color 0.25s ease',
...(isPending ? shimmerTextSx(c.accent.primary) : {}),
}}
>
{serviceLabel}
@@ -155,7 +156,7 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
)}
{canToggleDetails && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15, flexShrink: 0, opacity: 0, transition: 'opacity 120ms', '.osw-mcp-row:hover &': { opacity: 1 }, ...(showBody ? { opacity: 1 } : {}) }}>
{showBody ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
<ExpandMoreIcon sx={{ fontSize: 16, ...chevronSx(showBody) }} />
</IconButton>
)}
</Box>
@@ -177,10 +178,11 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
)}
</Box>
<Collapse in={showBody && canToggleDetails}>
<Collapse in={showBody && canToggleDetails} timeout={COLLAPSE_MS} easing={COLLAPSE_EASE}>
<Box
sx={{
bgcolor: tc.TERM_BG,
borderRadius: 1.5,
maxHeight: '60vh',
overflowY: 'auto',
overflowX: 'hidden',
@@ -4,7 +4,6 @@ import Typography from '@mui/material/Typography';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import TerminalIcon from '@mui/icons-material/Terminal';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import BlockIcon from '@mui/icons-material/Block';
@@ -25,6 +24,7 @@ import { domainFromUrl } from './SourceFavicons';
import { DomainIcon } from './DomainIcon';
import VendoredToolUi from '@toolui/VendoredToolUi';
import WidgetCopyChip from '../tool-ui/WidgetCopyChip';
import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx, railEnterSx } from './toolRowMotion';
interface DefaultToolBubbleProps {
call: AgentMessage;
@@ -119,6 +119,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
fontWeight: isPending ? 600 : 500,
flexShrink: 0,
transition: 'color 0.25s ease',
...(isPending ? shimmerTextSx(c.accent.primary) : {}),
}}
>
{(() => {
@@ -197,18 +198,14 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
{canToggleDetails && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: mcpCompact ? 0.15 : 0.25, flexShrink: 0, opacity: showBody ? 1 : 0, transition: 'opacity 120ms', '.osw-tool-row:hover &': { opacity: 1 } }}>
{showBody ? (
<ExpandLessIcon sx={{ fontSize: mcpCompact ? 16 : 18 }} />
) : (
<ExpandMoreIcon sx={{ fontSize: mcpCompact ? 16 : 18 }} />
)}
<ExpandMoreIcon sx={{ fontSize: mcpCompact ? 16 : 18, ...chevronSx(showBody) }} />
</IconButton>
)}
</Box>
<Collapse in={showBody && canToggleDetails}>
<Collapse in={showBody && canToggleDetails} timeout={COLLAPSE_MS} easing={COLLAPSE_EASE}>
{/* Standalone rows hang their output off the indent rail; compact rows already sit inside the group's rail. */}
<Box sx={mcpCompact ? {} : { borderLeft: `2px solid ${c.border.medium}`, ml: 0.8, pl: 0.75, my: 0.25 }}>
<Box sx={mcpCompact ? { ...railEnterSx(showBody) } : { borderLeft: `2px solid ${c.border.medium}`, ml: 0.8, pl: 0.75, my: 0.25, ...railEnterSx(showBody) }}>
{richRender ? (
<Box sx={{ p: 1, bgcolor: tc.TERM_BG, borderRadius: 1.5, position: 'relative', '&:hover .osw-widget-copy': { opacity: 1 } }}>
<WidgetCopyChip component={richRender.name} props={richRender.props} containerRef={richWidgetRef} />
@@ -4,11 +4,11 @@ import Typography from '@mui/material/Typography';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import TerminalIcon from '@mui/icons-material/Terminal';
import CheckIcon from '@mui/icons-material/Check';
import CircularProgress from '@mui/material/CircularProgress';
import { summarizeToolGroup } from './summarizeToolGroup';
import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx, railEnterSx, pressSx } from './toolRowMotion';
import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useMountReveal } from './useMountReveal';
@@ -159,11 +159,12 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
cursor: 'pointer',
color: c.text.tertiary,
'&:hover': { color: c.text.secondary },
...pressSx,
}}
>
{!allDone && <CircularProgress size={12} thickness={5} sx={{ color: c.accent.primary, flexShrink: 0 }} />}
{webDomains && webDomains.length > 0 && <SourceFavicons domains={webDomains} size={16} />}
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: 'inherit' }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: 'inherit', ...(allDone ? {} : shimmerTextSx(c.accent.primary)) }}>
{allDone ? (restingLabel ?? summarizeToolGroup(toolNames) ?? `Ran ${group.callCount} step${group.callCount === 1 ? '' : 's'}`) : (restingLabel ?? 'Working')}
</Typography>
{!allDone && group.callCount > 1 && (
@@ -181,7 +182,7 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
{deniedCount} denied
</Typography>
)}
<ExpandMoreIcon sx={{ fontSize: 15, transform: 'rotate(-90deg)' }} />
<ExpandMoreIcon sx={{ fontSize: 15, ...chevronSx(false) }} />
</Box>
) : (
<Box
@@ -193,6 +194,7 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
py: 0.4,
cursor: 'pointer',
'&:hover': { opacity: 0.85 },
...pressSx,
}}
>
{!allDone ? (
@@ -219,6 +221,7 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
fontWeight: 600,
flex: 1,
transition: 'color 200ms ease',
...(allDone ? {} : shimmerTextSx(c.accent.primary)),
}}
>
{displayName}
@@ -237,12 +240,12 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
</Typography>
)}
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15 }}>
<ExpandLessIcon sx={{ fontSize: 16 }} />
<ExpandMoreIcon sx={{ fontSize: 16, ...chevronSx(true) }} />
</IconButton>
</Box>
)}
<Collapse in={expanded}>
<Collapse in={expanded} timeout={COLLAPSE_MS} easing={COLLAPSE_EASE}>
<Box
sx={{
// ChatGPT's indent rail: detail hangs off a thin rule under the row, no enclosing box.
@@ -250,8 +253,9 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
ml: 0.8,
pl: 0.75,
my: 0.25,
...railEnterSx(expanded),
'& > *': {
animation: 'toolRowFadeIn 140ms ease-out backwards',
animation: `toolRowFadeIn ${COLLAPSE_MS}ms ${COLLAPSE_EASE} backwards`,
},
// Staggered entrance (assistant-ui's tool-group treatment): rows cascade instead of popping at once.
'& > *:nth-of-type(2)': { animationDelay: '40ms' },
@@ -0,0 +1,51 @@
// The motion vocabulary every tool disclosure shares, copied value-for-value from the best open
// implementations: assistant-ui's 200ms cubic-bezier(0.32,0.72,0,1) height choreography with a
// parallel fade + slide + 2px blur-in on the content, and open-webui's text shimmer
// (110deg sweep, 1.5s cubic-bezier(0.7,0,1,0.4)) for labels of tools still running.
export const COLLAPSE_MS = 200;
export const COLLAPSE_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
// Chevron spec: ONE glyph that rotates with the panel (never an icon swap): -90deg points right at
// rest, 0 when open, on the same duration/curve as the height so they move as a single object.
export function chevronSx(open: boolean): Record<string, unknown> {
return {
transform: open ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: `transform ${COLLAPSE_MS}ms ${COLLAPSE_EASE}`,
};
}
export function shimmerTextSx(color: string): Record<string, unknown> {
return {
WebkitTextFillColor: 'transparent',
background: `linear-gradient(110deg, color-mix(in srgb, ${color} 55%, transparent) 43%, ${color} 50%, color-mix(in srgb, ${color} 55%, transparent) 57%) 0 0 / 200% 100%`,
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'oswToolShimmer 1.5s cubic-bezier(0.7, 0, 1, 0.4) infinite',
'@keyframes oswToolShimmer': {
from: { backgroundPosition: '100% 0' },
to: { backgroundPosition: '-100% 0' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none', WebkitTextFillColor: 'unset', background: 'none', color },
};
}
// Content entrance layered over the height animation; the 2px blur-in is the expensive-feeling detail.
export function railEnterSx(open: boolean): Record<string, unknown> {
if (!open) return {};
return {
animation: `oswRailEnter ${COLLAPSE_MS}ms ${COLLAPSE_EASE}`,
'@keyframes oswRailEnter': {
from: { opacity: 0, transform: 'translateY(-4px)', filter: 'blur(2px)' },
to: { opacity: 1, transform: 'none', filter: 'none' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
};
}
// assistant-ui's free win: triggers acknowledge the press with a tiny origin-left squeeze.
export const pressSx: Record<string, unknown> = {
transformOrigin: 'left center',
transition: 'transform 100ms ease',
'&:active': { transform: 'scale(0.98)' },
};