[eric] canvas: collapsed cards show live tool steps as a checklist, the transition phase between Thinking and the answer

This commit is contained in:
ciregenz
2026-08-04 09:10:06 -07:00
parent 626cba568b
commit 6eb16e2969
3 changed files with 110 additions and 3 deletions
@@ -40,6 +40,7 @@ import AgentNarratorPill from '../desktop/AgentNarratorPill';
import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu';
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 { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { useBrowserPillShot } from '../desktop/useBrowserPillShot';
@@ -693,6 +694,10 @@ const AgentCard: React.FC<Props> = ({
// Desktop-shell narrator pill: a collapsed card with nothing to ask renders as the minimal pill
// (live turn label + plan checklist); approvals and drafts keep the full card so their UI has a home.
const todos = useMemo(() => extractLatestTodos(session.messages || []), [session.messages]);
const liveSteps = useMemo(
() => (session.status === 'running' ? extractLiveSteps(session.messages || []) : null),
[session.messages, session.status],
);
const pillArtifact = useMemo(() => {
const artifact = extractLatestShowUi(session.messages || []);
return artifact ? freezeIfDone(artifact, session.status === 'running') : null;
@@ -1032,6 +1037,7 @@ const AgentCard: React.FC<Props> = ({
label={pillLabel}
running={pillRunning}
todos={todos}
liveSteps={liveSteps}
artifact={pillArtifact}
askPair={pillAskPair}
sessionId={session.id}
@@ -10,11 +10,15 @@ import PillArtifactFrame from './PillArtifactFrame';
import type { ToolPair } from '@/app/pages/AgentChat/tool-bubbles/ToolCallBubble';
import { artifactName, type ShowUiPayload } from '@/app/pages/AgentChat/tool-ui/showUiPayload';
import type { AgentTodoItem } from './agentTodos';
import type { AgentLiveStep } from './agentLiveSteps';
import { shimmerTextSx } from '@/app/pages/AgentChat/tool-bubbles/toolRowMotion';
interface AgentNarratorPillProps {
label: string;
running: boolean;
todos: AgentTodoItem[] | null;
/** Tool activity of the live turn, the transition phase between "Thinking" and the answer. */
liveSteps: AgentLiveStep[] | null;
artifact: ShowUiPayload | null;
askPair?: ToolPair | null;
sessionId?: string;
@@ -29,14 +33,17 @@ const GLASS = GLASS_SURFACE;
const GLASS_BLUR = GLASS_SURFACE_BLUR;
const MAX_VISIBLE_TODOS = 4;
/** Collapsed agent as the desktop narrator pill; below it, the best artifact wins: live question > widget > browser shot > plan > Thinking. */
function AgentNarratorPill({ label, running, todos, artifact, askPair, sessionId, browserShot, finalText, selected, highlighted }: AgentNarratorPillProps): React.ReactElement {
/** Collapsed agent as the desktop narrator pill; below it, the best artifact wins: live question > widget > browser shot > plan > live steps > Thinking. */
function AgentNarratorPill({ label, running, todos, liveSteps, artifact, askPair, sessionId, browserShot, finalText, selected, highlighted }: AgentNarratorPillProps): React.ReactElement {
const visibleTodos = (todos || []).slice(0, MAX_VISIBLE_TODOS);
const hiddenCount = (todos?.length || 0) - visibleTodos.length;
// 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;
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.
const artifactKey = liveAsk ? `ask-${liveAsk.id}` : artifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : running ? 'thinking' : finalText ? 'final' : 'none';
const artifactKey = liveAsk ? `ask-${liveAsk.id}` : artifact ? 'widget' : browserShot ? 'shot' : visibleTodos.length > 0 ? 'todos' : visibleSteps.length > 0 ? 'steps' : running ? 'thinking' : finalText ? 'final' : 'none';
return (
<Box
@@ -159,6 +166,66 @@ function AgentNarratorPill({ label, running, todos, artifact, askPair, sessionId
</Typography>
)}
</Box>
) : visibleSteps.length > 0 ? (
// The transition phase: real tool activity as a simple checklist while the turn works.
<Box
key={artifactKey}
className="osw-artifact"
sx={{
borderRadius: '16px',
background: GLASS,
backdropFilter: GLASS_BLUR,
WebkitBackdropFilter: GLASS_BLUR,
boxShadow: '0 8px 24px rgba(0,0,0,0.32)',
px: 1.75,
py: 1.5,
minWidth: 200,
}}
>
{earlierSteps > 0 && (
<Typography sx={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.4)', pl: '2px', pb: 0.5 }}>
{earlierSteps} earlier step{earlierSteps === 1 ? '' : 's'}
</Typography>
)}
<Box sx={{ position: 'relative' }}>
{visibleSteps.length > 1 && (
<Box sx={{ position: 'absolute', left: 10, top: 12, bottom: 12, width: '2px', background: 'rgba(255,255,255,0.18)' }} />
)}
{visibleSteps.map((step, i) => (
<Box key={`${i}-${step.label.slice(0, 24)}`} sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.75 }}>
<Box
sx={{
width: 22,
height: 22,
borderRadius: '50%',
flexShrink: 0,
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: step.done ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.28)',
}}
>
{step.done && <CheckIcon sx={{ fontSize: 14, color: '#2a2a2a' }} />}
</Box>
<Typography
sx={{
fontSize: '0.8125rem',
fontWeight: 500,
color: 'rgba(255,255,255,0.92)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 260,
...(step.done ? {} : shimmerTextSx('rgba(255,255,255,0.92)')),
}}
>
{step.label}
</Typography>
</Box>
))}
</Box>
</Box>
) : running ? (
<Box
key={artifactKey}
@@ -0,0 +1,34 @@
import { getToolLabelWithInput } from '@/app/pages/AgentChat/parsing/toolLabels';
export interface AgentLiveStep {
label: string;
done: boolean;
}
// UI/meta tools are not "steps" a bystander cares about; the checklist is real work only.
const HIDDEN_TOOLS = /(^|__)(ShowUI|AskUI|AskUserQuestion|TodoWrite|MCPSearch|MCPActivate)$/i;
/** The collapsed card's transition phase when the agent made no TodoWrite plan: the current turn's
tool activity as a simple checklist, done steps checked, the live one last. */
export function extractLiveSteps(messages: Array<{ role: string; content: unknown; hidden?: boolean }>): AgentLiveStep[] | null {
let start = 0;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user' && !messages[i].hidden) { start = i + 1; break; }
}
const steps: AgentLiveStep[] = [];
for (let i = start; i < messages.length; i++) {
const m = messages[i];
if (m.role !== 'tool_call') continue;
const body = (typeof m.content === 'object' && m.content !== null ? m.content : {}) as { tool?: unknown; input?: unknown };
const tool = String(body.tool || '');
if (!tool || HIDDEN_TOOLS.test(tool)) continue;
const done = messages[i + 1]?.role === 'tool_result';
const lbl = getToolLabelWithInput(tool, body.input, (m as { id?: string }).id);
const label = done ? lbl.past : lbl.present;
// Consecutive same-verb steps merge so "Read a file" x8 doesn't fill the card.
const prev = steps[steps.length - 1];
if (prev && prev.label === label) { prev.done = prev.done && done; continue; }
steps.push({ label, done });
}
return steps.length ? steps : null;
}