[eric] canvas: a collapsed card shows the newest ShowUI a running skill emits; staleness is now work-after-the-widget, not its name (ENG-272)

This commit is contained in:
ciregenz
2026-08-12 09:20:53 -07:00
parent af94907144
commit e5bdca7d2a
3 changed files with 78 additions and 5 deletions
@@ -0,0 +1,46 @@
// Run: node --test (via frontend/scripts/run-tests.mjs)
//
// ENG-272. The collapsed pill hid a plan/progress widget whenever the session was running, on the
// assumption that a mid-turn plan is stale. That is true when work followed it and false when the
// widget IS the newest thing the agent said, which is exactly what a long-running skill does when it
// re-emits its tracker. Result: the minimized view showed older state all day. Staleness is now
// "did real work land after this widget", which these pin.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hasWorkAfterLatestShowUi } from './showUiPayload.ts';
const showUi = (id: string) => ({ role: 'tool_call', content: { id, tool: 'mcp__openswarm-core__ShowUI', input: { component: 'plan', props: {} } } });
const bash = (id: string) => ({ role: 'tool_call', content: { id, tool: 'Bash', input: { command: 'ls' } } });
const say = (text: string) => ({ role: 'assistant', content: text });
const user = (text: string) => ({ role: 'user', content: text });
test('a widget that is the newest thing is NOT stale', () => {
assert.equal(hasWorkAfterLatestShowUi([user('go'), bash('b1'), showUi('u1')]), false);
});
test('a re-emitted widget is not stale either, which is the whole bug', () => {
// The send-text case: work, widget, more work, widget again. The newest widget stands.
assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), bash('b1'), say('sent the batch'), showUi('u2')]), false);
});
test('a tool call after the widget DOES make it stale', () => {
assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), bash('b1')]), true);
});
test('a spoken answer after the widget makes it stale', () => {
assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), say('all done')]), true);
});
test('an empty assistant message is not work', () => {
assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), say(' ')]), false);
});
test('a user message after the widget is not agent work', () => {
// The user typing does not make the agent's own widget stale; the agent has not acted yet.
assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), user('any update?')]), false);
});
test('no widget at all reports no work-after', () => {
assert.equal(hasWorkAfterLatestShowUi([user('go'), bash('b1')]), true);
assert.equal(hasWorkAfterLatestShowUi([]), false);
});
@@ -125,6 +125,26 @@ export function extractPendingAskUi(messages: Array<{ id: string; role: string;
return null;
}
/** True when real agent work landed AFTER the newest ShowUI, which is what makes that widget stale.
*
* A tool call or a spoken answer after the widget means the plan it drew has moved on. A widget that
* is still the last thing in the transcript is the agent's CURRENT statement, however it is named,
* and a long-running skill re-emitting its tracker lands exactly there (ENG-272).
*/
export function hasWorkAfterLatestShowUi(messages: Array<{ role: string; content: unknown }>): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === 'tool_call') {
const body = (typeof msg.content === 'object' && msg.content !== null ? msg.content : {}) as { tool?: unknown };
// The ShowUI itself ends the scan: anything below it is older than the widget.
if (/(^|__)ShowUI$/.test(String(body.tool || ''))) return false;
return true;
}
if (msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.trim()) return true;
}
return false;
}
export function extractLatestShowUi(messages: Array<{ role: string; content: unknown }>): ShowUiPayload | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
@@ -43,7 +43,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, artifactName } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
import { extractLatestShowUi, extractPendingAskUi, freezeIfDone, artifactName, hasWorkAfterLatestShowUi } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { useBrowserPillShot } from '../desktop/useBrowserPillShot';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -724,11 +724,18 @@ const AgentCard: React.FC<Props> = ({
[session.messages, session.status],
);
const pillArtifact = useMemo(() => {
const artifact = extractLatestShowUi(session.messages || []);
const msgs = session.messages || [];
const artifact = extractLatestShowUi(msgs);
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;
// A plan/progress widget posted mid-turn goes stale the moment work continues, so while running
// the pill prefers living surfaces (browser shot, live steps, todos). But "stale" means NOTHING
// HAPPENED SINCE, not "it is named plan": a skill whose whole job is a live-updating tracker
// re-emits it as the newest thing in the transcript, and hiding that left the collapsed view
// showing older state all day (ENG-272). So only suppress it when real work followed it.
if (session.status === 'running' && /plan|progress/i.test(artifactName(artifact))
&& hasWorkAfterLatestShowUi(msgs)) {
return null;
}
return freezeIfDone(artifact, session.status === 'running');
}, [session.messages, session.status]);
const pillAskPair = useMemo(