mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 13:32:22 +02:00
[eric] canvas: pill widgets fail silent, stale plan widgets yield to living surfaces, asks sit on one glass card
This commit is contained in:
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user