mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 10:17:43 +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 === 'plan') return <PlanWidget props={payload.props} />;
|
||||||
if (payload.component === 'stats') return <StatsWidget props={payload.props} />;
|
if (payload.component === 'stats') return <StatsWidget props={payload.props} />;
|
||||||
if (payload.component === 'links') return <LinksWidget 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardCont
|
|||||||
import { agentCardMenuRows } from './agentCardMenuRows';
|
import { agentCardMenuRows } from './agentCardMenuRows';
|
||||||
import { extractLatestTodos } from '../desktop/agentTodos';
|
import { extractLatestTodos } from '../desktop/agentTodos';
|
||||||
import { extractLiveSteps } from '../desktop/agentLiveSteps';
|
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 { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
|
||||||
import { useBrowserPillShot } from '../desktop/useBrowserPillShot';
|
import { useBrowserPillShot } from '../desktop/useBrowserPillShot';
|
||||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||||
@@ -700,7 +700,11 @@ const AgentCard: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
const pillArtifact = useMemo(() => {
|
const pillArtifact = useMemo(() => {
|
||||||
const artifact = extractLatestShowUi(session.messages || []);
|
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]);
|
}, [session.messages, session.status]);
|
||||||
const pillAskPair = useMemo(
|
const pillAskPair = useMemo(
|
||||||
() => (session.status === 'running' ? extractPendingAskUi(session.messages || []) : null),
|
() => (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.
|
// 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 visibleSteps = running && !visibleTodos.length ? (liveSteps || []).slice(-MAX_VISIBLE_TODOS) : [];
|
||||||
const earlierSteps = running && !visibleTodos.length ? Math.max(0, (liveSteps?.length || 0) - visibleSteps.length) : 0;
|
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
|
const shownArtifact = artifact;
|
||||||
// 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 ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined;
|
const ring = selected || highlighted ? { outline: '2px solid #3b82f6', outlineOffset: '2px' } : undefined;
|
||||||
const liveAsk = askPair && sessionId ? askPair : null;
|
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.
|
// 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 ? (
|
{liveAsk ? (
|
||||||
<PillArtifactFrame key={artifactKey} name="question">
|
<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>
|
</PillArtifactFrame>
|
||||||
) : shownArtifact ? (
|
) : shownArtifact ? (
|
||||||
<PillArtifactFrame key={artifactKey} name={artifactName(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 { useThemeMode } from '@/shared/styles/ThemeContext';
|
||||||
import { TOOL_UI_REGISTRY } from './registry';
|
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
|
// 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}
|
// 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 {
|
render(): React.ReactNode {
|
||||||
if (this.state.failed) {
|
if (this.state.failed) {
|
||||||
|
if (this.props.quiet) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ fontSize: '0.75rem', opacity: 0.45, padding: '4px 0', fontStyle: 'italic' }}>
|
<div style={{ fontSize: '0.75rem', opacity: 0.45, padding: '4px 0', fontStyle: 'italic' }}>
|
||||||
Couldn't draw the {this.props.name.replace(/-/g, ' ')} view
|
Couldn't draw the {this.props.name.replace(/-/g, ' ')} view
|
||||||
@@ -34,6 +35,8 @@ interface VendoredToolUiProps {
|
|||||||
props: Record<string, unknown>;
|
props: Record<string, unknown>;
|
||||||
/** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */
|
/** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */
|
||||||
extraProps?: Record<string, unknown>;
|
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 =
|
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. */
|
/** 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 { mode } = useThemeMode();
|
||||||
const entry = TOOL_UI_REGISTRY[name];
|
const entry = TOOL_UI_REGISTRY[name];
|
||||||
const [gate, setGate] = useState<Gate>({ state: 'pending' });
|
const [gate, setGate] = useState<Gate>({ state: 'pending' });
|
||||||
@@ -104,6 +107,7 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React
|
|||||||
if (gate.state === 'bad') {
|
if (gate.state === 'bad') {
|
||||||
// Schema jargon is for the console; the transcript gets one quiet human line.
|
// Schema jargon is for the console; the transcript gets one quiet human line.
|
||||||
console.warn(`[tool-ui] ${name} payload didn't validate:`, gate.problem);
|
console.warn(`[tool-ui] ${name} payload didn't validate:`, gate.problem);
|
||||||
|
if (quietFail) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ fontSize: '0.75rem', opacity: 0.45, padding: '4px 0', fontStyle: 'italic' }}>
|
<div style={{ fontSize: '0.75rem', opacity: 0.45, padding: '4px 0', fontStyle: 'italic' }}>
|
||||||
Couldn't draw the {name.replace(/-/g, ' ')} view
|
Couldn't draw the {name.replace(/-/g, ' ')} view
|
||||||
@@ -111,12 +115,12 @@ function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (gate.state === 'pending') {
|
if (gate.state === 'pending') {
|
||||||
return <SkeletonBlock name={name} />;
|
return quietFail ? null : <SkeletonBlock name={name} />;
|
||||||
}
|
}
|
||||||
const Component = entry.Component;
|
const Component = entry.Component;
|
||||||
return (
|
return (
|
||||||
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
|
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
|
||||||
<ComponentGuard name={name}>
|
<ComponentGuard name={name} quiet={quietFail}>
|
||||||
<Suspense fallback={<SkeletonBlock name={name} />}>
|
<Suspense fallback={<SkeletonBlock name={name} />}>
|
||||||
<Component {...gate.parsed} {...(extraProps || {})} />
|
<Component {...gate.parsed} {...(extraProps || {})} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
Reference in New Issue
Block a user