diff --git a/BACKEND_UI_CHANGES.md b/BACKEND_UI_CHANGES.md index 1d0eea2..89749ad 100644 --- a/BACKEND_UI_CHANGES.md +++ b/BACKEND_UI_CHANGES.md @@ -87,7 +87,7 @@ The Electron main process maps this to a `SessionEventRecord` with `kind: 'agent - "Code Reviewer is using read_file…" - "Handing off to Summarizer…" -The activity panel in `ChatPane.tsx` is now wired to this data, showing every agent in the pattern with observed activity such as thinking, tool usage, handoff, or completed. If no event has been observed for an agent yet, the UI now states that no status has been reported instead of inventing a synthetic state. +The activity panel in `ChatPane.tsx` is now wired to this data, showing every agent in the pattern with observed activity such as thinking, tool usage, handoff, or completed. If no event has been observed for an agent yet, the UI states that no status has been reported instead of inventing a synthetic state. The panel also keeps the last observed statuses visible after a run completes, and resets them when the next run begins. ## Files involved diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index caa9984..b2d4f7a 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -50,6 +50,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner int fallbackMessageIndex = 0; List completedMessages = []; AgentIdentity? activeAgent = null; + HashSet startedAgents = new(StringComparer.OrdinalIgnoreCase); + HashSet completedAgents = new(StringComparer.OrdinalIgnoreCase); await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); @@ -57,16 +59,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) { if (evt is ExecutorInvokedEvent invoked - && TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent) - && (!activeAgent.HasValue - || !string.Equals(activeAgent.Value.AgentId, invokedAgent.AgentId, StringComparison.Ordinal))) + && TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent)) { activeAgent = invokedAgent; - - await onActivity(CreateActivityEvent( + await EmitThinkingIfNeeded( command, - activityType: "thinking", - agent: invokedAgent)).ConfigureAwait(false); + invokedAgent, + startedAgents, + onActivity).ConfigureAwait(false); } else if (evt is RequestInfoEvent requestInfo) { @@ -89,6 +89,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner if (TryResolveKnownAgentIdentity(command.Pattern, update.ExecutorId, out AgentIdentity updateAgent)) { activeAgent = updateAgent; + await EmitThinkingIfNeeded( + command, + updateAgent, + startedAgents, + onActivity).ConfigureAwait(false); } await onDelta(new TurnDeltaEventDto @@ -102,22 +107,32 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner }).ConfigureAwait(false); } else if (evt is ExecutorCompletedEvent completed - && TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent) - && activeAgent.HasValue - && string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal)) + && TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent)) { - await onActivity(CreateActivityEvent( - command, - activityType: "completed", - agent: completedAgent)).ConfigureAwait(false); + if (completedAgents.Add(completedAgent.AgentId)) + { + await onActivity(CreateActivityEvent( + command, + activityType: "completed", + agent: completedAgent)).ConfigureAwait(false); + } - activeAgent = null; + if (activeAgent.HasValue + && string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal)) + { + activeAgent = null; + } } else if (evt is WorkflowOutputEvent outputEvent) { List allMessages = outputEvent.As>() ?? []; List newMessages = allMessages.Skip(inputMessages.Count).ToList(); completedMessages = ConvertOutputMessages(command, newMessages, segments); + await EmitCompletedActivitiesForMessages( + command, + completedMessages, + completedAgents, + onActivity).ConfigureAwait(false); } } @@ -167,6 +182,48 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner toolName: toolName); } + private static async Task EmitThinkingIfNeeded( + RunTurnCommandDto command, + AgentIdentity agent, + ISet startedAgents, + Func onActivity) + { + if (!startedAgents.Add(agent.AgentId)) + { + return; + } + + await onActivity(CreateActivityEvent( + command, + activityType: "thinking", + agent: agent)).ConfigureAwait(false); + } + + private static async Task EmitCompletedActivitiesForMessages( + RunTurnCommandDto command, + IReadOnlyList messages, + ISet completedAgents, + Func onActivity) + { + foreach (ChatMessageDto message in messages) + { + if (!TryResolveKnownAgentIdentity(command.Pattern, message.AuthorName, out AgentIdentity messageAgent)) + { + continue; + } + + if (!completedAgents.Add(messageAgent.AgentId)) + { + continue; + } + + await onActivity(CreateActivityEvent( + command, + activityType: "completed", + agent: messageAgent)).ConfigureAwait(false); + } + } + private static bool TryGetHandoffTarget( PatternDefinitionDto pattern, RequestInfoEvent requestInfo, diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index c3cd43a..5221a96 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -40,6 +40,8 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa () => buildAgentActivityRows(activity, pattern.agents), [activity, pattern.agents], ); + const hasObservedActivity = activityRows.some((row) => !!row.activity); + const showActivityPanel = (isBusy || hasObservedActivity) && activityRows.length > 0; useEffect(() => { transcriptRef.current?.scrollTo({ @@ -138,7 +140,7 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa })} - {isBusy && activityRows.length > 0 && ( + {showActivityPanel && (
Agent activity diff --git a/src/renderer/lib/sessionActivity.ts b/src/renderer/lib/sessionActivity.ts index fda3020..7fed371 100644 --- a/src/renderer/lib/sessionActivity.ts +++ b/src/renderer/lib/sessionActivity.ts @@ -42,15 +42,11 @@ export function applySessionEventActivity( } if (event.kind === 'status') { - if (event.status === 'running' || event.status === 'idle' || event.status === 'error') { + if (event.status === 'running') { return removeSessionActivity(current, event.sessionId); } } - if (event.kind === 'error') { - return removeSessionActivity(current, event.sessionId); - } - return current; } diff --git a/tests/renderer/sessionActivity.test.ts b/tests/renderer/sessionActivity.test.ts index c860e0e..774f864 100644 --- a/tests/renderer/sessionActivity.test.ts +++ b/tests/renderer/sessionActivity.test.ts @@ -83,7 +83,7 @@ describe('session activity helpers', () => { }); }); - test('clears stale activity when a session restarts or finishes', () => { + test('clears stale activity when a session restarts', () => { const current: SessionActivityMap = { 'session-1': { architect: { @@ -111,17 +111,36 @@ describe('session activity helpers', () => { ).toEqual({ 'session-2': current['session-2'], }); + }); + + test('keeps the last observed status after completion or error', () => { + const current: SessionActivityMap = { + 'session-1': { + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'completed', + }, + }, + }; expect( applySessionEventActivity(current, { - sessionId: 'session-2', - kind: 'error', + sessionId: 'session-1', + kind: 'status', occurredAt: '2026-03-23T00:00:00.000Z', + status: 'idle', + }), + ).toEqual(current); + + expect( + applySessionEventActivity(current, { + sessionId: 'session-1', + kind: 'error', + occurredAt: '2026-03-23T00:00:01.000Z', error: 'Boom', }), - ).toEqual({ - 'session-1': current['session-1'], - }); + ).toEqual(current); }); test('builds rows for all agents with sensible defaults', () => {