From 9cd83b7fd94de7f9d419096fd5b95af192b07832 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 10:20:01 -0700 Subject: [PATCH] [eric] canvas: pill widgets fail silent, stale plan widgets yield to living surfaces, asks sit on one glass card --- .../app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx | 2 +- frontend/src/app/pages/Dashboard/cards/AgentCard.tsx | 8 ++++++-- .../pages/Dashboard/desktop/AgentNarratorPill.tsx | 10 +++++----- frontend/src/toolui/VendoredToolUi.tsx | 12 ++++++++---- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx index d4b78c07..488e05fa 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx +++ b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx @@ -12,7 +12,7 @@ function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambien if (payload.component === 'plan') return ; if (payload.component === 'stats') return ; if (payload.component === 'links') return ; - if (payload.component === 'vendored') return ; + if (payload.component === 'vendored') return ; return null; } diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 7ddbc50c..e9bbf784 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -41,7 +41,7 @@ import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardCont import { agentCardMenuRows } from './agentCardMenuRows'; import { extractLatestTodos } from '../desktop/agentTodos'; import { extractLiveSteps } from '../desktop/agentLiveSteps'; -import { extractLatestShowUi, extractPendingAskUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; +import { extractLatestShowUi, extractPendingAskUi, freezeIfDone, artifactName } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; import { useBrowserPillShot } from '../desktop/useBrowserPillShot'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -700,7 +700,11 @@ const AgentCard: React.FC = ({ ); const pillArtifact = useMemo(() => { const artifact = extractLatestShowUi(session.messages || []); - return artifact ? freezeIfDone(artifact, session.status === 'running') : null; + if (!artifact) return null; + // A plan/progress widget posted mid-turn goes stale the moment work continues; while running the + // pill prefers living surfaces (browser shot, live steps, todos). Answer widgets still win. + if (session.status === 'running' && /plan|progress/i.test(artifactName(artifact))) return null; + return freezeIfDone(artifact, session.status === 'running'); }, [session.messages, session.status]); const pillAskPair = useMemo( () => (session.status === 'running' ? extractPendingAskUi(session.messages || []) : null), diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx index d8e8200f..e2c361c2 100644 --- a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -40,10 +40,7 @@ function AgentNarratorPill({ label, running, todos, liveSteps, artifact, askPair // Live tool steps window to the most recent, since earlier ones are history, not plan. const visibleSteps = running && !visibleTodos.length ? (liveSteps || []).slice(-MAX_VISIBLE_TODOS) : []; const earlierSteps = running && !visibleTodos.length ? Math.max(0, (liveSteps?.length || 0) - visibleSteps.length) : 0; - // A plan/progress widget the agent posted once goes STALE while work continues; live steps outrank - // it until the turn ends. Answer-shaped widgets (weather, tables) still win the ladder. - const staleplan = running && artifact && /plan|progress/i.test(artifactName(artifact)) && visibleSteps.length > 0; - const shownArtifact = staleplan ? null : artifact; + const shownArtifact = artifact; const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined; const liveAsk = askPair && sessionId ? askPair : null; // One key per ladder state so a state CHANGE remounts the artifact and replays the one-shot entrance; nothing loops. @@ -93,7 +90,10 @@ function AgentNarratorPill({ label, running, todos, liveSteps, artifact, askPair {liveAsk ? ( - + {/* One glass surface holds the whole ask (options + Confirm + the type-your-own field); without it the widget's footer floated bare on the canvas. */} + + + ) : shownArtifact ? ( diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx index b930cb82..eb1da00d 100644 --- a/frontend/src/toolui/VendoredToolUi.tsx +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -2,7 +2,7 @@ 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 } +interface GuardProps { name: string; quiet?: boolean; 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} @@ -19,6 +19,7 @@ class ComponentGuard extends React.Component { render(): React.ReactNode { if (this.state.failed) { + if (this.props.quiet) return null; return (
Couldn't draw the {this.props.name.replace(/-/g, ' ')} view @@ -34,6 +35,8 @@ interface VendoredToolUiProps { props: Record; /** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */ extraProps?: Record; + /** Ambient surfaces (the collapsed pill) show NOTHING on failure; a floating error line on the canvas is worse than absence. */ + quietFail?: boolean; } type Gate = @@ -83,7 +86,7 @@ const SkeletonBlock: React.FC<{ name: string }> = ({ name }) => ( ); /** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */ -function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React.ReactElement | null { +function VendoredToolUi({ name, props, extraProps, quietFail = false }: VendoredToolUiProps): React.ReactElement | null { const { mode } = useThemeMode(); const entry = TOOL_UI_REGISTRY[name]; const [gate, setGate] = useState({ state: 'pending' }); @@ -104,6 +107,7 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React if (gate.state === 'bad') { // Schema jargon is for the console; the transcript gets one quiet human line. console.warn(`[tool-ui] ${name} payload didn't validate:`, gate.problem); + if (quietFail) return null; return (
Couldn't draw the {name.replace(/-/g, ' ')} view @@ -111,12 +115,12 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React ); } if (gate.state === 'pending') { - return ; + return quietFail ? null : ; } const Component = entry.Component; return (
- + }>