[eric] chat: assistant answers drop the bubble (user-only bubbles); tool-ui cards get skeleton + blur-in entrance

This commit is contained in:
ciregenz
2026-07-21 18:40:19 -07:00
parent 647e3c4ff0
commit 76cf209eda
2 changed files with 41 additions and 3 deletions
@@ -26,7 +26,22 @@ function ToolUiBubble({ pair, sessionId, isPending, suppressReveal, sessionRunni
);
}
return (
<Box sx={{ my: 1, contain: 'layout style' }} data-select-type="tool-ui" data-select-id={pair.id} data-select-meta={JSON.stringify({ component: payload.component })}>
<Box
sx={{
my: 1,
contain: 'layout style',
// One-shot entrance (assistant-ui's fade + rise + blur-in): the card arrives, it doesn't pop.
animation: 'toolUiEnter 240ms cubic-bezier(0.32, 0.72, 0, 1)',
'@keyframes toolUiEnter': {
from: { opacity: 0, transform: 'translateY(6px)', filter: 'blur(2px)' },
to: { opacity: 1, transform: 'translateY(0)', filter: 'blur(0)' },
},
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
}}
data-select-type="tool-ui"
data-select-id={pair.id}
data-select-meta={JSON.stringify({ component: payload.component })}
>
<ShowUiWidgetView payload={payload} />
</Box>
);
+25 -2
View File
@@ -60,6 +60,29 @@ function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Recor
return { state: 'bad', problem: issues };
}
// Rough resting height per component family so the loading skeleton reserves believable space
// (Lobe/Open WebUI pattern: a breathing block where the card will land, not a tiny sliver).
function skeletonHeightFor(name: string): number {
if (/table|chart|gallery|map|carousel|post|terminal/.test(name)) return 180;
if (/stats|weather|plan|order|preferences|question/.test(name)) return 110;
return 56;
}
const SkeletonBlock: React.FC<{ name: string }> = ({ name }) => (
<div
style={{
height: skeletonHeightFor(name),
width: '100%',
maxWidth: 520,
borderRadius: 12,
background: 'rgba(127,127,127,0.12)',
animation: 'toolui-skeleton-pulse 1.4s ease-in-out infinite',
}}
>
<style>{'@keyframes toolui-skeleton-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }'}</style>
</div>
);
/** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */
function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React.ReactElement | null {
const { mode } = useThemeMode();
@@ -87,13 +110,13 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React
);
}
if (gate.state === 'pending') {
return <div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />;
return <SkeletonBlock name={name} />;
}
const Component = entry.Component;
return (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
<ComponentGuard name={name}>
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
<Suspense fallback={<SkeletonBlock name={name} />}>
<Component {...gate.parsed} {...(extraProps || {})} />
</Suspense>
</ComponentGuard>