[eric] canvas: pill widgets fail silent, stale plan widgets yield to living surfaces, asks sit on one glass card

This commit is contained in:
ciregenz
2026-08-04 10:20:01 -07:00
parent 707836d926
commit 9cd83b7fd9
4 changed files with 20 additions and 12 deletions
@@ -12,7 +12,7 @@ function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambien
if (payload.component === 'plan') return <PlanWidget props={payload.props} />;
if (payload.component === 'stats') return <StatsWidget props={payload.props} />;
if (payload.component === 'links') return <LinksWidget props={payload.props} />;
if (payload.component === 'vendored') return <VendoredToolUi name={payload.name} props={payload.props} />;
if (payload.component === 'vendored') return <VendoredToolUi name={payload.name} props={payload.props} quietFail={ambient} />;
return null;
}
@@ -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<Props> = ({
);
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),
@@ -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 ? (
<PillArtifactFrame key={artifactKey} name="question">
<AskUiBubble pair={liveAsk} sessionId={sessionId!} isPending suppressReveal />
{/* 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. */}
<Box sx={{ borderRadius: '16px', background: GLASS, backdropFilter: GLASS_BLUR, WebkitBackdropFilter: GLASS_BLUR, boxShadow: '0 8px 24px rgba(0,0,0,0.32)', px: 1.25, py: 1.25 }}>
<AskUiBubble pair={liveAsk} sessionId={sessionId!} isPending suppressReveal />
</Box>
</PillArtifactFrame>
) : shownArtifact ? (
<PillArtifactFrame key={artifactKey} name={artifactName(shownArtifact)}>
+8 -4
View File
@@ -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<GuardProps, { failed: boolean }> {
render(): React.ReactNode {
if (this.state.failed) {
if (this.props.quiet) return null;
return (
<div style={{ fontSize: '0.75rem', opacity: 0.45, padding: '4px 0', fontStyle: 'italic' }}>
Couldn't draw the {this.props.name.replace(/-/g, ' ')} view
@@ -34,6 +35,8 @@ interface VendoredToolUiProps {
props: Record<string, unknown>;
/** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */
extraProps?: Record<string, unknown>;
/** 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<Gate>({ 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 (
<div style={{ fontSize: '0.75rem', opacity: 0.45, padding: '4px 0', fontStyle: 'italic' }}>
Couldn't draw the {name.replace(/-/g, ' ')} view
@@ -111,12 +115,12 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React
);
}
if (gate.state === 'pending') {
return <SkeletonBlock name={name} />;
return quietFail ? null : <SkeletonBlock name={name} />;
}
const Component = entry.Component;
return (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
<ComponentGuard name={name}>
<ComponentGuard name={name} quiet={quietFail}>
<Suspense fallback={<SkeletonBlock name={name} />}>
<Component {...gate.parsed} {...(extraProps || {})} />
</Suspense>