diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py index 216fbe3c..5fe0d19e 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -101,6 +101,8 @@ def compose_turn_system_prompt( "Strongly prefer rendering rich UI over prose, every time the content fits:\n" "- ShowUI for any structured result: tables, stats, links, plans, progress, code, diffs, " "charts, maps, media, posts, receipts. Render the component, then add one line of text.\n" + "- For multi-step work, render a progress-tracker FIRST and re-call ShowUI with the SAME " + "props.id after each step so the card advances live; same-id re-calls update in place.\n" "- AskUI for ANY question with enumerable choices, an approval, or tunable values: render " "it and wait for the answer instead of asking in prose. Flat choices = option-list; a " "multi-question form = one AskUI call per question in sequence. The user can always " diff --git a/backend/apps/agents/show_ui_mcp_server.py b/backend/apps/agents/show_ui_mcp_server.py index c8004e42..88164f3d 100644 --- a/backend/apps/agents/show_ui_mcp_server.py +++ b/backend/apps/agents/show_ui_mcp_server.py @@ -34,7 +34,7 @@ def p_load_generated(): GENERATED = p_load_generated() COMPONENT_SPECS = { - "weather": "props: {location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high?: number, low?: number}] (max 7)}", + "weather": "props: {id?: str, location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high?: number, low?: number}] (max 7)}", "stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}", "links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}", } @@ -82,7 +82,10 @@ TOOLS = [ "Use it whenever a result fits one of the shapes. Supported components:\n" + "\n".join(f"- '{name}': {spec}" for name, spec in COMPONENT_SPECS.items()) + "\nCall it with the component name and a props object matching that shape. " - "The component renders in place of raw text; still give a one-line text summary after." + "The component renders in place of raw text; still give a one-line text summary after. " + "LIVE UPDATES: calling ShowUI again with the SAME component and props.id updates that " + "card in place. Use this to advance progress-tracker/plan step statuses AS you complete " + "each step of real work, or to refresh data; never mint a new id for an update." ), "inputSchema": { "type": "object", diff --git a/backend/apps/agents/toolui_schemas.json b/backend/apps/agents/toolui_schemas.json index 254665b8..3e91b536 100644 --- a/backend/apps/agents/toolui_schemas.json +++ b/backend/apps/agents/toolui_schemas.json @@ -1806,7 +1806,7 @@ } }, "item-carousel": { - "hint": "props: {id: str, name: str, subtitle?: str, image?: str, color?: str, actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|'outline', loading?: bool, disabled?: bool, shortcut?: str}]}", + "hint": "props: {id: str, items: [{id: str, name: str, subtitle?: str, image?: str, color?: str, actions?: [obj]}], title?: str, description?: str}", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -1815,21 +1815,13 @@ "type": "string", "minLength": 1 }, - "name": { - "type": "string", - "minLength": 1 - }, - "subtitle": { + "title": { "type": "string" }, - "image": { - "type": "string", - "format": "uri" - }, - "color": { + "description": { "type": "string" }, - "actions": { + "items": { "type": "array", "items": { "type": "object", @@ -1838,46 +1830,76 @@ "type": "string", "minLength": 1 }, - "label": { + "name": { "type": "string", "minLength": 1 }, - "sentence": { + "subtitle": { "type": "string" }, - "confirmLabel": { - "type": "string" - }, - "variant": { + "image": { "type": "string", - "enum": [ - "default", - "destructive", - "secondary", - "ghost", - "outline" - ] + "format": "uri" }, - "loading": { - "type": "boolean" - }, - "disabled": { - "type": "boolean" - }, - "shortcut": { + "color": { "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "sentence": { + "type": "string" + }, + "confirmLabel": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "destructive", + "secondary", + "ghost", + "outline" + ] + }, + "loading": { + "type": "boolean" + }, + "disabled": { + "type": "boolean" + }, + "shortcut": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } } }, "required": [ "id", - "label" + "name" ] } } }, "required": [ "id", - "name" + "items" ] } }, diff --git a/frontend/scripts/gen-toolui-hints.ts b/frontend/scripts/gen-toolui-hints.ts index 15b0b782..75f42631 100644 --- a/frontend/scripts/gen-toolui-hints.ts +++ b/frontend/scripts/gen-toolui-hints.ts @@ -15,7 +15,7 @@ const TARGETS: Record = { 'image': ['../src/toolui/components/image/schema', 'SerializableImageSchema'], 'image-gallery': ['../src/toolui/components/image-gallery/schema', 'SerializableImageGallerySchema'], 'instagram-post': ['../src/toolui/components/instagram-post/schema', 'SerializableInstagramPostSchema'], - 'item-carousel': ['../src/toolui/components/item-carousel/schema', 'SerializableItemSchema'], + 'item-carousel': ['../src/toolui/components/item-carousel/schema', 'SerializableItemCarouselSchema'], 'link-preview': ['../src/toolui/components/link-preview/schema', 'SerializableLinkPreviewSchema'], 'linkedin-post': ['../src/toolui/components/linkedin-post/schema', 'SerializableLinkedInPostSchema'], 'message-draft': ['../src/toolui/components/message-draft/schema', 'SerializableEmailDraftSchema'], diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index e866039f..e2077877 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1058,6 +1058,25 @@ const AgentChat: React.FC = ({ 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(); + const latestByKey = new Map(); + const keyByCallId = new Map(); + 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 = ({ 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; diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx index 7cc5ff04..fdbbd0c3 100644 --- a/frontend/src/toolui/VendoredToolUi.tsx +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -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 { + constructor(props: GuardProps) { + super(props); + this.state = { failed: false }; + } + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true }; + } + + render(): React.ReactNode { + if (this.state.failed) { + return ( +
+ {this.props.name} failed to render +
+ ); + } + return this.props.children; + } +} + interface VendoredToolUiProps { name: string; props: Record; @@ -65,9 +92,11 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React const Component = entry.Component; return (
- }> - - + + }> + + +
); } diff --git a/frontend/src/toolui/registry.tsx b/frontend/src/toolui/registry.tsx index 06791380..39e54561 100644 --- a/frontend/src/toolui/registry.tsx +++ b/frontend/src/toolui/registry.tsx @@ -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

(Inner: React.ComponentType<{ post: P }>): React.ComponentType

{ + return function PostAdapter(props: P) { + return ; + }; +} + export const TOOL_UI_REGISTRY: Record = { 'audio': { Component: lazy(() => import('./components/audio').then((m) => ({ default: m.Audio }))), @@ -52,19 +59,19 @@ export const TOOL_UI_REGISTRY: Record = { 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 = { 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), }, };