diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index ebf67cae..7ddbc50c 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -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 = ({ // 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 = ({ label={pillLabel} running={pillRunning} todos={todos} + liveSteps={liveSteps} artifact={pillArtifact} askPair={pillAskPair} sessionId={session.id} diff --git a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx index 8d0e5c65..416b8187 100644 --- a/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/AgentNarratorPill.tsx @@ -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 ( )} + ) : visibleSteps.length > 0 ? ( + // The transition phase: real tool activity as a simple checklist while the turn works. + + {earlierSteps > 0 && ( + + {earlierSteps} earlier step{earlierSteps === 1 ? '' : 's'} + + )} + + {visibleSteps.length > 1 && ( + + )} + {visibleSteps.map((step, i) => ( + + + {step.done && } + + + {step.label} + + + ))} + + ) : running ? ( ): 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; +}