From e79cbe8df94ba39d40bb2b89718067f4dd1678ed Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 7 Apr 2026 08:44:18 +0200 Subject: [PATCH] feat: improve agent status and thinking indicator UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show agent activity label in chat header (Thinking…, Using bash…, etc.) instead of an anonymous pulsing dot - Fix ThinkingProcess elapsed timer to tick live every second using useElapsedTimer hook instead of stale useMemo computation - Show completed/failed subagents for 3s grace period with fade-out transition instead of instantly hiding them - Transition activity labels to Completed state for 1.5s grace period on session idle instead of abrupt removal - Eliminate message flash during thinking reclassification by folding trailing pending assistant messages into the thinking group optimistically when they follow existing thinking messages - Stabilize ThinkingProcess isActive flag by checking for pending messages in the group rather than relying solely on array position - Skip rendering empty pending messages inside ThinkingProcess steps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/App.tsx | 26 +++++ src/renderer/components/ChatPane.tsx | 79 +++++++++++--- .../components/chat/SubagentActivityCard.tsx | 67 ++++++++++-- .../components/chat/ThinkingProcess.tsx | 26 +++-- src/renderer/hooks/useElapsedTimer.ts | 39 +++++++ src/renderer/lib/sessionActivity.ts | 70 ++++++++++-- tests/renderer/sessionActivity.test.ts | 100 ++++++++++++++++++ 7 files changed, 366 insertions(+), 41 deletions(-) create mode 100644 src/renderer/hooks/useElapsedTimer.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index aee3a1d..92c3e2f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -26,6 +26,7 @@ import { pruneSessionUsage, pruneSessionRequestUsage, pruneTurnEventLogs, + purgeCompletedActivity, type SessionActivityMap, type SessionUsageMap, type SessionRequestUsageMap, @@ -131,6 +132,7 @@ export default function App() { const [sessionRequestUsage, setSessionRequestUsage] = useState({}); const [turnEventLogs, setTurnEventLogs] = useState({}); const [activeSubagents, setActiveSubagents] = useState({}); + const activityPurgeTimers = useRef>>(new Map()); const [showSettings, setShowSettings] = useState(false); const [settingsSection, setSettingsSection] = useState(); @@ -206,12 +208,35 @@ export default function App() { setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event)); setTurnEventLogs((current) => applyTurnEventLog(current, event)); setActiveSubagents((current) => applySubagentEvent(current, event)); + + // Schedule purge of completed activity labels after grace period + if (event.kind === 'status' && event.status === 'idle') { + const existing = activityPurgeTimers.current.get(event.sessionId); + if (existing) clearTimeout(existing); + activityPurgeTimers.current.set( + event.sessionId, + setTimeout(() => { + setSessionActivities((current) => purgeCompletedActivity(current, event.sessionId)); + activityPurgeTimers.current.delete(event.sessionId); + }, 1500), + ); + } + // Cancel pending purge if a new run starts + if (event.kind === 'status' && event.status === 'running') { + const existing = activityPurgeTimers.current.get(event.sessionId); + if (existing) { + clearTimeout(existing); + activityPurgeTimers.current.delete(event.sessionId); + } + } }); return () => { disposed = true; offWorkspace(); offSessionEvent(); + for (const timer of activityPurgeTimers.current.values()) clearTimeout(timer); + activityPurgeTimers.current.clear(); }; }, [api]); @@ -675,6 +700,7 @@ export default function App() { runtimeTools={sidecarCapabilities?.runtimeTools} session={selectedSession} sessionUsage={usageForSession} + sessionActivity={activityForSession} activeSubagents={subagentsForSession} terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'} terminalRunning={terminalRunning} diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 6e0b550..22d4a6d 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -19,7 +19,8 @@ import type { ApprovalDecision } from '@shared/domain/approval'; import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment'; -import type { SessionUsageState } from '@renderer/lib/sessionActivity'; +import type { SessionUsageState, SessionActivityState } from '@renderer/lib/sessionActivity'; +import { summarizeSessionActivity } from '@renderer/lib/sessionActivity'; import type { ActiveSubagent } from '@renderer/lib/subagentTracker'; import { findModel, @@ -55,6 +56,7 @@ interface ChatPaneProps { mcpProbingServerIds?: string[]; runtimeTools?: ReadonlyArray; sessionUsage?: SessionUsageState; + sessionActivity?: SessionActivityState; activeSubagents?: ReadonlyArray; terminalOpen?: boolean; terminalRunning?: boolean; @@ -92,6 +94,7 @@ export function ChatPane({ mcpProbingServerIds, runtimeTools, sessionUsage, + sessionActivity, activeSubagents, terminalOpen, terminalRunning, @@ -133,20 +136,44 @@ export function ChatPane({ const items: DisplayItem[] = []; let pendingThinking: ChatMessageRecord[] = []; let lastUserMessageId: string | undefined; + const messages = session.messages; + const busy = session.status === 'running'; + + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + const isLast = i === messages.length - 1; - for (const message of session.messages) { if (message.messageKind === 'thinking') { pendingThinking.push(message); - } else { - if (pendingThinking.length > 0) { - const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined; - items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt }); - pendingThinking = []; - } - items.push({ type: 'message', message }); - if (message.role === 'user') { - lastUserMessageId = message.id; - } + continue; + } + + // Optimistic thinking classification: fold a pending assistant + // message into the current thinking group when it immediately follows + // thinking messages during an active turn. This prevents the brief + // flash of content that occurs before a message-reclassified event + // arrives. Only the very last message qualifies — earlier messages + // have already been classified definitively. + if ( + busy + && isLast + && pendingThinking.length > 0 + && message.role === 'assistant' + && message.pending + && !message.messageKind + ) { + pendingThinking.push(message); + continue; + } + + if (pendingThinking.length > 0) { + const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined; + items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt }); + pendingThinking = []; + } + items.push({ type: 'message', message }); + if (message.role === 'user') { + lastUserMessageId = message.id; } } @@ -156,7 +183,7 @@ export function ChatPane({ } return items; - }, [session.messages, session.runs]); + }, [session.messages, session.runs, session.status]); const lastThinkingGroupIndex = useMemo(() => { for (let i = displayItems.length - 1; i >= 0; i--) { @@ -165,6 +192,19 @@ export function ChatPane({ return -1; }, [displayItems]); + // A thinking group is active when the session is running AND the group + // contains at least one pending message (currently streaming). This is + // more robust than relying purely on array position, which can be stale + // during reclassification gaps. + const isThinkingGroupActive = useCallback( + (group: { messages: ChatMessageRecord[] }, groupIndex: number): boolean => { + if (!isSessionBusy) return false; + if (groupIndex !== lastThinkingGroupIndex) return false; + return group.messages.some((m) => m.pending); + }, + [isSessionBusy, lastThinkingGroupIndex], + ); + const lastAssistantId = useMemo(() => { for (let i = session.messages.length - 1; i >= 0; i--) { const m = session.messages[i]; @@ -416,7 +456,15 @@ export function ChatPane({ Awaiting your input )} - {isSessionBusy && !pendingApproval && !pendingUserInput && } + {isSessionBusy && !pendingApproval && !pendingUserInput && (() => { + const label = summarizeSessionActivity(sessionActivity); + return ( +
+ + {label ?? 'Working…'} +
+ ); + })()} {session.status === 'error' && (
@@ -464,12 +512,11 @@ export function ChatPane({
{displayItems.map((item, itemIndex) => { if (item.type === 'thinking-group') { - const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex; return (
diff --git a/src/renderer/components/chat/SubagentActivityCard.tsx b/src/renderer/components/chat/SubagentActivityCard.tsx index a7e5ecd..8593f4d 100644 --- a/src/renderer/components/chat/SubagentActivityCard.tsx +++ b/src/renderer/components/chat/SubagentActivityCard.tsx @@ -1,8 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'; import type { ActiveSubagent } from '@renderer/lib/subagentTracker'; +const COMPLETION_GRACE_MS = 3000; + function formatElapsed(startedAt: string): string { const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000); if (seconds < 60) return `${seconds}s`; @@ -37,9 +39,10 @@ function ElapsedTimer({ startedAt }: { startedAt: string }) { interface SubagentActivityCardProps { subagent: ActiveSubagent; + fading?: boolean; } -function SubagentActivityCard({ subagent }: SubagentActivityCardProps) { +function SubagentActivityCard({ subagent, fading }: SubagentActivityCardProps) { const borderClass = subagent.status === 'running' ? 'border-[var(--color-accent-sky)]/20' @@ -49,7 +52,7 @@ function SubagentActivityCard({ subagent }: SubagentActivityCardProps) { return (
@@ -73,16 +76,68 @@ interface SubagentActivityListProps { } export function SubagentActivityList({ subagents }: SubagentActivityListProps) { + // Track recently-completed subagent IDs so we can show them briefly + const [recentlyDone, setRecentlyDone] = useState>(new Set()); + const [fading, setFading] = useState>(new Set()); + const timersRef = useRef>>(new Map()); + + useEffect(() => { + for (const sub of subagents) { + if (sub.status !== 'running' && !recentlyDone.has(sub.toolCallId) && !timersRef.current.has(sub.toolCallId)) { + // Newly completed — track it + setRecentlyDone((prev) => new Set(prev).add(sub.toolCallId)); + + const fadeTimer = setTimeout(() => { + setFading((prev) => new Set(prev).add(sub.toolCallId)); + }, COMPLETION_GRACE_MS - 300); + + const removeTimer = setTimeout(() => { + setRecentlyDone((prev) => { + const next = new Set(prev); + next.delete(sub.toolCallId); + return next; + }); + setFading((prev) => { + const next = new Set(prev); + next.delete(sub.toolCallId); + return next; + }); + timersRef.current.delete(sub.toolCallId); + }, COMPLETION_GRACE_MS); + + timersRef.current.set(sub.toolCallId, removeTimer); + // Store fade timer for cleanup + timersRef.current.set(`fade-${sub.toolCallId}`, fadeTimer); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subagents]); + + // Cleanup timers on unmount + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + }; + }, []); + if (subagents.length === 0) return null; - // Only show running subagents in the chat stream - const visible = subagents.filter((s) => s.status === 'running'); + // Show running subagents + recently-completed ones within the grace period + const visible = subagents.filter( + (s) => s.status === 'running' || recentlyDone.has(s.toolCallId), + ); if (visible.length === 0) return null; return (
{visible.map((subagent) => ( - + ))}
); diff --git a/src/renderer/components/chat/ThinkingProcess.tsx b/src/renderer/components/chat/ThinkingProcess.tsx index 95ea1ab..2d724f2 100644 --- a/src/renderer/components/chat/ThinkingProcess.tsx +++ b/src/renderer/components/chat/ThinkingProcess.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Brain, ChevronDown, ChevronRight } from 'lucide-react'; +import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer'; import type { ChatMessageRecord } from '@shared/domain/session'; interface ThinkingProcessProps { @@ -26,24 +27,22 @@ export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingP const toggle = useCallback(() => setExpanded((prev) => !prev), []); - const elapsed = useMemo(() => { - if (!turnStartedAt || messages.length === 0) return undefined; - const start = new Date(turnStartedAt).getTime(); - const lastMessage = messages[messages.length - 1]; - const end = isActive ? Date.now() : new Date(lastMessage.createdAt).getTime(); - const seconds = Math.max(0, Math.round((end - start) / 1000)); - if (seconds < 2) return undefined; - return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`; - }, [turnStartedAt, messages, isActive]); + const elapsed = useElapsedTimer( + messages.length > 0 ? turnStartedAt : undefined, + isActive, + ); if (messages.length === 0) { return null; } - const stepCount = messages.length; + // Don't count empty pending messages (optimistically folded in) toward step count + const stepCount = messages.filter((m) => m.content).length; const summaryParts: string[] = []; if (elapsed) summaryParts.push(`${elapsed}`); - summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`); + if (stepCount > 0) { + summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`); + } return (
@@ -87,6 +86,11 @@ export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingP function ThinkingStep({ message }: { message: ChatMessageRecord }) { const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]); + // Pending message folded into thinking group with no content yet + if (message.pending && !message.content) { + return null; + } + return (
diff --git a/src/renderer/hooks/useElapsedTimer.ts b/src/renderer/hooks/useElapsedTimer.ts new file mode 100644 index 0000000..c62fede --- /dev/null +++ b/src/renderer/hooks/useElapsedTimer.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; + +function formatElapsed(startMs: number): string { + const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${minutes}m ${remainder}s`; +} + +/** + * Returns a live-ticking elapsed-time string while `active` is true. + * Freezes the display when `active` becomes false. + */ +export function useElapsedTimer(startedAt: string | undefined, active: boolean): string | undefined { + const startMs = startedAt ? new Date(startedAt).getTime() : undefined; + + const [elapsed, setElapsed] = useState(() => { + if (!startMs) return undefined; + return formatElapsed(startMs); + }); + + useEffect(() => { + if (!startMs) { + setElapsed(undefined); + return; + } + + // Always sync to current value immediately + setElapsed(formatElapsed(startMs)); + + if (!active) return; + + const id = setInterval(() => setElapsed(formatElapsed(startMs)), 1000); + return () => clearInterval(id); + }, [startMs, active]); + + return elapsed; +} diff --git a/src/renderer/lib/sessionActivity.ts b/src/renderer/lib/sessionActivity.ts index 8111a74..13d77c9 100644 --- a/src/renderer/lib/sessionActivity.ts +++ b/src/renderer/lib/sessionActivity.ts @@ -166,12 +166,16 @@ function clearActiveSessionActivity( return current; } + // Transition active entries to 'completed' instead of removing them. + // The UI layer uses a grace timer to eventually clear these. let changed = false; const nextSessionActivity = Object.fromEntries( - Object.entries(sessionActivity).filter(([, activity]) => { - const keep = !isAgentActivityActive(activity); - changed ||= !keep; - return keep; + Object.entries(sessionActivity).map(([key, activity]) => { + if (isAgentActivityActive(activity)) { + changed = true; + return [key, { ...activity, activityType: 'completed' as const }]; + } + return [key, activity]; }), ); @@ -179,10 +183,6 @@ function clearActiveSessionActivity( return current; } - if (Object.keys(nextSessionActivity).length === 0) { - return removeSessionActivity(current, sessionId); - } - return { ...current, [sessionId]: nextSessionActivity, @@ -466,6 +466,60 @@ export function pruneSessionRequestUsage( /* ── Formatting helpers ─────────────────────────────────────── */ +/** + * Remove all completed activity entries for a session. + * Called after the UI grace period to finish clearing stale labels. + */ +export function purgeCompletedActivity( + current: SessionActivityMap, + sessionId: string, +): SessionActivityMap { + const sessionActivity = current[sessionId]; + if (!sessionActivity) return current; + + const nextSessionActivity = Object.fromEntries( + Object.entries(sessionActivity).filter(([, a]) => !isAgentActivityCompleted(a)), + ); + + if (Object.keys(nextSessionActivity).length === Object.keys(sessionActivity).length) { + return current; + } + + if (Object.keys(nextSessionActivity).length === 0) { + return removeSessionActivity(current, sessionId); + } + + return { ...current, [sessionId]: nextSessionActivity }; +} + +/** + * Returns a single summary label for the most relevant activity in a session. + * Prefers active states (thinking → tool-calling → handoff) over completed. + */ +export function summarizeSessionActivity( + sessionActivity: SessionActivityState | undefined, +): string | undefined { + if (!sessionActivity) return undefined; + + const entries = Object.values(sessionActivity); + if (entries.length === 0) return undefined; + + // Prefer the first active entry by priority + const active = entries.find((a) => a.activityType === 'thinking') + ?? entries.find((a) => a.activityType === 'tool-calling') + ?? entries.find((a) => a.activityType === 'handoff'); + + if (active) { + return formatAgentActivityLabel(active); + } + + // If everything is completed, show that + const completed = entries.find((a) => a.activityType === 'completed'); + if (completed) return 'Completed'; + + return undefined; +} + export function formatTokenCount(tokens: number): string { if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; diff --git a/tests/renderer/sessionActivity.test.ts b/tests/renderer/sessionActivity.test.ts index 3be8a6b..6e224f6 100644 --- a/tests/renderer/sessionActivity.test.ts +++ b/tests/renderer/sessionActivity.test.ts @@ -13,6 +13,8 @@ import { isAgentActivityCompleted, pruneSessionActivities, pruneSessionRequestUsage, + purgeCompletedActivity, + summarizeSessionActivity, type SessionActivityMap, type SessionRequestUsageMap, } from '@renderer/lib/sessionActivity'; @@ -202,6 +204,7 @@ describe('session activity helpers', () => { }, }; + // Active agents transition to 'completed' on idle (grace period before purge) expect( applySessionEventActivity(current, { sessionId: 'session-1', @@ -216,6 +219,11 @@ describe('session activity helpers', () => { agentName: 'Architect', activityType: 'completed', }, + reviewer: { + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'completed', + }, }, }); }); @@ -236,6 +244,7 @@ describe('session activity helpers', () => { }, }; + // Active agents transition to 'completed' on cancel (grace period before purge) expect( applySessionEventActivity(current, { sessionId: 'session-1', @@ -265,6 +274,11 @@ describe('session activity helpers', () => { agentName: 'Architect', activityType: 'completed', }, + reviewer: { + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'completed', + }, }, }); }); @@ -385,6 +399,92 @@ describe('session activity helpers', () => { 'session-2': current['session-2'], }); }); + + test('purgeCompletedActivity removes completed entries after grace period', () => { + const current: SessionActivityMap = { + 'session-1': { + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'completed', + }, + reviewer: { + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'completed', + }, + }, + }; + + const result = purgeCompletedActivity(current, 'session-1'); + expect(result['session-1']).toBeUndefined(); + }); + + test('purgeCompletedActivity is a no-op when nothing is completed', () => { + const current: SessionActivityMap = { + 'session-1': { + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'thinking', + }, + }, + }; + expect(purgeCompletedActivity(current, 'session-1')).toBe(current); + }); + + test('summarizeSessionActivity returns the most relevant label', () => { + expect(summarizeSessionActivity(undefined)).toBeUndefined(); + expect(summarizeSessionActivity({})).toBeUndefined(); + + expect( + summarizeSessionActivity({ + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'thinking', + }, + }), + ).toBe('Thinking…'); + + expect( + summarizeSessionActivity({ + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'tool-calling', + toolName: 'bash', + }, + }), + ).toBe('Using bash…'); + + // Thinking takes priority over tool-calling + expect( + summarizeSessionActivity({ + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'thinking', + }, + reviewer: { + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'tool-calling', + toolName: 'read_file', + }, + }), + ).toBe('Thinking…'); + + expect( + summarizeSessionActivity({ + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'completed', + }, + }), + ).toBe('Completed'); + }); }); describe('assistant usage accumulator', () => {