mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 19:27:45 +02:00
[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:
@@ -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)' },
|
||||
};
|
||||
Reference in New Issue
Block a user