diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index 606ddbac..6a66afd3 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -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 = ({ 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 = ({ )} {canToggleDetails && ( - {showBody ? : } + )} @@ -177,10 +178,11 @@ export const CompactMcpBubble: React.FC = ({ )} - + = ({ 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 = ({ {canToggleDetails && ( - {showBody ? ( - - ) : ( - - )} + )} - + {/* Standalone rows hang their output off the indent rail; compact rows already sit inside the group's rail. */} - + {richRender ? ( diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx index d7762529..f5baa1a8 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx @@ -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 = React.memo(({ group, isSessionRunning = cursor: 'pointer', color: c.text.tertiary, '&:hover': { color: c.text.secondary }, + ...pressSx, }} > {!allDone && } {webDomains && webDomains.length > 0 && } - + {allDone ? (restingLabel ?? summarizeToolGroup(toolNames) ?? `Ran ${group.callCount} step${group.callCount === 1 ? '' : 's'}`) : (restingLabel ?? 'Working')} {!allDone && group.callCount > 1 && ( @@ -181,7 +182,7 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = {deniedCount} denied )} - + ) : ( = React.memo(({ group, isSessionRunning = py: 0.4, cursor: 'pointer', '&:hover': { opacity: 0.85 }, + ...pressSx, }} > {!allDone ? ( @@ -219,6 +221,7 @@ const ToolGroupBubble: React.FC = 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 = React.memo(({ group, isSessionRunning = )} - + )} - + = 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' }, diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/toolRowMotion.ts b/frontend/src/app/pages/AgentChat/tool-bubbles/toolRowMotion.ts new file mode 100644 index 00000000..909be423 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/toolRowMotion.ts @@ -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 { + return { + transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', + transition: `transform ${COLLAPSE_MS}ms ${COLLAPSE_EASE}`, + }; +} + +export function shimmerTextSx(color: string): Record { + 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 { + 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 = { + transformOrigin: 'left center', + transition: 'transform 100ms ease', + '&:active': { transform: 'scale(0.98)' }, +};