[eric] ui-tools: live in-place card updates (same component+id); social posts wrapped as {post} (flat props crashed the shell); per-component error wall; item-carousel validates the carousel shape

This commit is contained in:
ciregenz
2026-07-20 17:25:55 -07:00
parent b8911f28cf
commit cd0ddcb6b5
7 changed files with 143 additions and 45 deletions
+36 -1
View File
@@ -1058,6 +1058,25 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const renderItems: RenderItem[] = useMemo(() => {
const items: RenderItem[] = [];
let i = 0;
// Live-updating cards: repeated ShowUI calls with the SAME component+props.id are one card that
// UPDATES IN PLACE at its first position (progress advances, data refreshes), never a stack of
// stale snapshots. Pre-scan maps each id key to its first slot and its latest call+result.
const firstCallIdByKey = new Map<string, string>();
const latestByKey = new Map<string, { call: (typeof activeBranchMessages)[number]; result: (typeof activeBranchMessages)[number] | null }>();
const keyByCallId = new Map<string, string>();
for (let s = 0; s < activeBranchMessages.length; s++) {
const m = activeBranchMessages[s];
const mc = m.content;
if (m.role !== 'tool_call' || typeof mc !== 'object' || !/(^|__)ShowUI$/.test(String(mc?.tool || ''))) continue;
const input = mc?.input as { component?: unknown; props?: { id?: unknown } } | undefined;
const compId = input?.props?.id;
if (!input?.component || typeof compId !== 'string' || !compId) continue;
const key = `${input.component}:${compId}`;
keyByCallId.set(m.id, key);
if (!firstCallIdByKey.has(key)) firstCallIdByKey.set(key, m.id);
const next = activeBranchMessages[s + 1];
latestByKey.set(key, { call: m, result: next && next.role === 'tool_result' ? next : null });
}
// Narration that led INTO a tool phase; folds into that phase's group on a finished session.
let leadNotes: typeof activeBranchMessages = [];
while (i < activeBranchMessages.length) {
@@ -1177,7 +1196,23 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
// Phase held only ShowUI/AskUI pairs: narration has no group to fold into, keep it visible.
for (const nm of noteMarks) items.push(nm.msg);
}
items.push(...showUiPairs);
for (const p of showUiPairs) {
const key = keyByCallId.get(p.call.id);
if (!key || isAskUiPair(p)) {
items.push(p);
continue;
}
// Later updates render nowhere themselves; the first slot always shows the latest call
// under a STABLE key so React updates the mounted component instead of remounting it.
if (p.call.id !== firstCallIdByKey.get(key)) continue;
const latest = latestByKey.get(key);
items.push({
type: 'tool_pair' as const,
id: `showui-${key}`,
call: latest ? latest.call : p.call,
result: latest ? latest.result : p.result,
});
}
} else {
if (!sessionRunning && msg.role === 'assistant') {
let j = i;
+32 -3
View File
@@ -2,6 +2,33 @@ import React, { Suspense, useEffect, useState } from 'react';
import { useThemeMode } from '@/shared/styles/ThemeContext';
import { TOOL_UI_REGISTRY } from './registry';
interface GuardProps { name: string; children: React.ReactNode }
// A component render throwing must cost exactly one quiet line, never the app: the top-level
// ErrorBoundary unmounts the whole shell for any uncaught child throw (the linkedin-post {post}
// mismatch took down the dashboard until this wall existed).
class ComponentGuard extends React.Component<GuardProps, { failed: boolean }> {
constructor(props: GuardProps) {
super(props);
this.state = { failed: false };
}
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true };
}
render(): React.ReactNode {
if (this.state.failed) {
return (
<div style={{ fontSize: '0.75rem', opacity: 0.55, padding: '4px 0' }}>
{this.props.name} failed to render
</div>
);
}
return this.props.children;
}
}
interface VendoredToolUiProps {
name: string;
props: Record<string, unknown>;
@@ -65,9 +92,11 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React
const Component = entry.Component;
return (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
<Component {...gate.parsed} {...(extraProps || {})} />
</Suspense>
<ComponentGuard name={name}>
<Suspense fallback={<div style={{ height: 48, width: 280, borderRadius: 12, background: 'rgba(127,127,127,0.12)' }} />}>
<Component {...gate.parsed} {...(extraProps || {})} />
</Suspense>
</ComponentGuard>
</div>
);
}
+11 -4
View File
@@ -10,6 +10,13 @@ export interface ToolUiEntry {
/* Every entry lazy-loads both the component and its zod contract so the chat bundle only pays
for components a transcript actually uses. Names mirror upstream tool-ui component slugs. */
// The social-post components take their data nested as {post}; the wire props ARE the post.
function wrapAsPost<P extends { id?: unknown }>(Inner: React.ComponentType<{ post: P }>): React.ComponentType<P> {
return function PostAdapter(props: P) {
return <Inner post={props} />;
};
}
export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
'audio': {
Component: lazy(() => import('./components/audio').then((m) => ({ default: m.Audio }))),
@@ -52,19 +59,19 @@ export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
loadSchema: () => import('./components/image-gallery/schema').then((m) => m.SerializableImageGallerySchema),
},
'instagram-post': {
Component: lazy(() => import('./components/instagram-post').then((m) => ({ default: m.InstagramPost }))),
Component: lazy(() => import('./components/instagram-post').then((m) => ({ default: wrapAsPost(m.InstagramPost) }))),
loadSchema: () => import('./components/instagram-post/schema').then((m) => m.SerializableInstagramPostSchema),
},
'item-carousel': {
Component: lazy(() => import('./components/item-carousel').then((m) => ({ default: m.ItemCarousel }))),
loadSchema: () => import('./components/item-carousel/schema').then((m) => m.SerializableItemSchema),
loadSchema: () => import('./components/item-carousel/schema').then((m) => m.SerializableItemCarouselSchema),
},
'link-preview': {
Component: lazy(() => import('./components/link-preview').then((m) => ({ default: m.LinkPreview }))),
loadSchema: () => import('./components/link-preview/schema').then((m) => m.SerializableLinkPreviewSchema),
},
'linkedin-post': {
Component: lazy(() => import('./components/linkedin-post').then((m) => ({ default: m.LinkedInPost }))),
Component: lazy(() => import('./components/linkedin-post').then((m) => ({ default: wrapAsPost(m.LinkedInPost) }))),
loadSchema: () => import('./components/linkedin-post/schema').then((m) => m.SerializableLinkedInPostSchema),
},
'message-draft': {
@@ -112,7 +119,7 @@ export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
loadSchema: () => import('./components/video/schema').then((m) => m.SerializableVideoSchema),
},
'x-post': {
Component: lazy(() => import('./components/x-post').then((m) => ({ default: m.XPost }))),
Component: lazy(() => import('./components/x-post').then((m) => ({ default: wrapAsPost(m.XPost) }))),
loadSchema: () => import('./components/x-post/schema').then((m) => m.SerializableXPostSchema),
},
};