mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 21:03:58 +02:00
feat: show per-agent activity statuses
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+10
-8
@@ -42,6 +42,7 @@ export interface SessionEventRecord {
|
|||||||
|
|
||||||
// New fields for 'agent-activity' events
|
// New fields for 'agent-activity' events
|
||||||
activityType?: 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
activityType?: 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||||
|
agentId?: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,7 @@ In the sidecar's turn-execution pipeline, emit a new JSON event type alongside t
|
|||||||
"requestId": "…",
|
"requestId": "…",
|
||||||
"sessionId": "…",
|
"sessionId": "…",
|
||||||
"activityType": "tool-calling",
|
"activityType": "tool-calling",
|
||||||
|
"agentId": "agent-reviewer",
|
||||||
"agentName": "Code Reviewer",
|
"agentName": "Code Reviewer",
|
||||||
"toolName": "read_file"
|
"toolName": "read_file"
|
||||||
}
|
}
|
||||||
@@ -79,25 +81,25 @@ The Electron main process maps this to a `SessionEventRecord` with `kind: 'agent
|
|||||||
|
|
||||||
### Renderer consumption
|
### Renderer consumption
|
||||||
|
|
||||||
`App.tsx` now subscribes to `onSessionEvent` and tracks the latest activity event for the selected session. `ChatPane.tsx` uses that state to show contextual messages like:
|
`App.tsx` now subscribes to `onSessionEvent` and tracks live activity per agent for the selected session. `ChatPane.tsx` uses that state to show a status row for each agent while the run is active.
|
||||||
|
|
||||||
- "Code Reviewer is thinking…"
|
- "Code Reviewer is thinking…"
|
||||||
- "Code Reviewer is using read_file…"
|
- "Code Reviewer is using read_file…"
|
||||||
- "Handing off to Summarizer…"
|
- "Handing off to Summarizer…"
|
||||||
|
|
||||||
The `ThinkingDots` component and activity indicator section in `ChatPane.tsx` are now wired to this data, with completed activity rendering as text-only status instead of an animated waiting state.
|
The activity panel in `ChatPane.tsx` is now wired to this data, showing every agent in the pattern with a current status such as waiting, thinking, tool usage, handoff, or completed.
|
||||||
|
|
||||||
## Files involved
|
## Files involved
|
||||||
|
|
||||||
| Layer | File | Change |
|
| Layer | File | Change |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Shared | `src/shared/domain/event.ts` | Add `'agent-activity'` to `SessionEventKind`, add optional `activityType` / `agentName` / `toolName` fields |
|
| Shared | `src/shared/domain/event.ts` | Add `'agent-activity'` to `SessionEventKind`, add optional `activityType` / `agentId` / `agentName` / `toolName` fields |
|
||||||
| Shared | `src/shared/contracts/sidecar.ts` | Add `AgentActivityEvent` to the sidecar event union |
|
| Shared | `src/shared/contracts/sidecar.ts` | Add `AgentActivityEvent` to the sidecar event union, including a stable optional `agentId` |
|
||||||
| Main | `src/main/sidecar/sidecarProcess.ts` | Parse `agent-activity` events from sidecar JSON output |
|
| Main | `src/main/sidecar/sidecarProcess.ts` | Parse `agent-activity` events from sidecar JSON output |
|
||||||
| Main | `src/main/KopayaAppService.ts` | Map parsed activity events to `SessionEventRecord` and emit via `session-event` |
|
| Main | `src/main/KopayaAppService.ts` | Map parsed activity events to `SessionEventRecord` and emit via `session-event` |
|
||||||
| Renderer | `src/renderer/App.tsx` | Subscribe to `onSessionEvent` and track live activity state per session |
|
| Renderer | `src/renderer/App.tsx` | Subscribe to `onSessionEvent` and track live per-agent activity state per session |
|
||||||
| Renderer | `src/renderer/components/ChatPane.tsx` | Render contextual activity text in the existing activity indicator |
|
| Renderer | `src/renderer/components/ChatPane.tsx` | Render an activity row for each agent while a session is running |
|
||||||
| Renderer | `src/renderer/lib/sessionActivity.ts` | Provide pure helpers for activity-state updates and display text |
|
| Renderer | `src/renderer/lib/sessionActivity.ts` | Provide pure helpers for per-agent activity-state updates and display text |
|
||||||
| Sidecar | `sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs` | Define `AgentActivityEventDto` |
|
| Sidecar | `sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs` | Define `AgentActivityEventDto`, including `agentId` |
|
||||||
| Sidecar | `sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs` | Emit `agent-activity` events during MAF turn execution when observable |
|
| Sidecar | `sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs` | Emit `agent-activity` events during MAF turn execution when observable |
|
||||||
| Sidecar | `sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs` | Forward activity events over the stdio protocol |
|
| Sidecar | `sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs` | Forward activity events over the stdio protocol |
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto
|
|||||||
{
|
{
|
||||||
public string SessionId { get; init; } = string.Empty;
|
public string SessionId { get; init; } = string.Empty;
|
||||||
public string ActivityType { get; init; } = string.Empty;
|
public string ActivityType { get; init; } = string.Empty;
|
||||||
|
public string? AgentId { get; init; }
|
||||||
public string? AgentName { get; init; }
|
public string? AgentName { get; init; }
|
||||||
public string? ToolName { get; init; }
|
public string? ToolName { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions");
|
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions");
|
||||||
private readonly PatternValidator _patternValidator;
|
private readonly PatternValidator _patternValidator;
|
||||||
|
|
||||||
|
private readonly record struct AgentIdentity(string AgentId, string AgentName);
|
||||||
|
|
||||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||||
{
|
{
|
||||||
_patternValidator = patternValidator;
|
_patternValidator = patternValidator;
|
||||||
@@ -47,8 +49,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
List<StreamingSegment> segments = [];
|
List<StreamingSegment> segments = [];
|
||||||
int fallbackMessageIndex = 0;
|
int fallbackMessageIndex = 0;
|
||||||
List<ChatMessageDto> completedMessages = [];
|
List<ChatMessageDto> completedMessages = [];
|
||||||
string? activeAgentId = null;
|
AgentIdentity? activeAgent = null;
|
||||||
string? activeAgentName = null;
|
|
||||||
|
|
||||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
||||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||||
@@ -56,23 +57,23 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
if (evt is ExecutorInvokedEvent invoked
|
if (evt is ExecutorInvokedEvent invoked
|
||||||
&& TryResolveKnownAgentName(command.Pattern, invoked.ExecutorId, out string invokedAgentName)
|
&& TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent)
|
||||||
&& !string.Equals(activeAgentId, invoked.ExecutorId, StringComparison.Ordinal))
|
&& (!activeAgent.HasValue
|
||||||
|
|| !string.Equals(activeAgent.Value.AgentId, invokedAgent.AgentId, StringComparison.Ordinal)))
|
||||||
{
|
{
|
||||||
activeAgentId = invoked.ExecutorId;
|
activeAgent = invokedAgent;
|
||||||
activeAgentName = invokedAgentName;
|
|
||||||
|
|
||||||
await onActivity(CreateActivityEvent(
|
await onActivity(CreateActivityEvent(
|
||||||
command,
|
command,
|
||||||
activityType: "thinking",
|
activityType: "thinking",
|
||||||
agentName: invokedAgentName)).ConfigureAwait(false);
|
agent: invokedAgent)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else if (evt is RequestInfoEvent requestInfo)
|
else if (evt is RequestInfoEvent requestInfo)
|
||||||
{
|
{
|
||||||
AgentActivityEventDto? activity = TryCreateActivityFromRequest(
|
AgentActivityEventDto? activity = TryCreateActivityFromRequest(
|
||||||
command,
|
command,
|
||||||
requestInfo,
|
requestInfo,
|
||||||
activeAgentName);
|
activeAgent);
|
||||||
|
|
||||||
if (activity is not null)
|
if (activity is not null)
|
||||||
{
|
{
|
||||||
@@ -85,10 +86,9 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
StreamingSegment segment = GetOrCreateSegment(segments, messageId, update.ExecutorId);
|
StreamingSegment segment = GetOrCreateSegment(segments, messageId, update.ExecutorId);
|
||||||
segment.Content.Append(update.Update.Text);
|
segment.Content.Append(update.Update.Text);
|
||||||
|
|
||||||
if (TryResolveKnownAgentName(command.Pattern, update.ExecutorId, out string updateAgentName))
|
if (TryResolveKnownAgentIdentity(command.Pattern, update.ExecutorId, out AgentIdentity updateAgent))
|
||||||
{
|
{
|
||||||
activeAgentId = update.ExecutorId;
|
activeAgent = updateAgent;
|
||||||
activeAgentName = updateAgentName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await onDelta(new TurnDeltaEventDto
|
await onDelta(new TurnDeltaEventDto
|
||||||
@@ -102,16 +102,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else if (evt is ExecutorCompletedEvent completed
|
else if (evt is ExecutorCompletedEvent completed
|
||||||
&& TryResolveKnownAgentName(command.Pattern, completed.ExecutorId, out string completedAgentName)
|
&& TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent)
|
||||||
&& string.Equals(activeAgentId, completed.ExecutorId, StringComparison.Ordinal))
|
&& activeAgent.HasValue
|
||||||
|
&& string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
await onActivity(CreateActivityEvent(
|
await onActivity(CreateActivityEvent(
|
||||||
command,
|
command,
|
||||||
activityType: "completed",
|
activityType: "completed",
|
||||||
agentName: completedAgentName)).ConfigureAwait(false);
|
agent: completedAgent)).ConfigureAwait(false);
|
||||||
|
|
||||||
activeAgentId = null;
|
activeAgent = null;
|
||||||
activeAgentName = null;
|
|
||||||
}
|
}
|
||||||
else if (evt is WorkflowOutputEvent outputEvent)
|
else if (evt is WorkflowOutputEvent outputEvent)
|
||||||
{
|
{
|
||||||
@@ -127,7 +127,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
private static AgentActivityEventDto CreateActivityEvent(
|
private static AgentActivityEventDto CreateActivityEvent(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
string activityType,
|
string activityType,
|
||||||
string agentName,
|
AgentIdentity agent,
|
||||||
string? toolName = null)
|
string? toolName = null)
|
||||||
{
|
{
|
||||||
return new AgentActivityEventDto
|
return new AgentActivityEventDto
|
||||||
@@ -136,7 +136,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
RequestId = command.RequestId,
|
RequestId = command.RequestId,
|
||||||
SessionId = command.SessionId,
|
SessionId = command.SessionId,
|
||||||
ActivityType = activityType,
|
ActivityType = activityType,
|
||||||
AgentName = agentName,
|
AgentId = agent.AgentId,
|
||||||
|
AgentName = agent.AgentName,
|
||||||
ToolName = toolName,
|
ToolName = toolName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -144,17 +145,17 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
private static AgentActivityEventDto? TryCreateActivityFromRequest(
|
private static AgentActivityEventDto? TryCreateActivityFromRequest(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
RequestInfoEvent requestInfo,
|
RequestInfoEvent requestInfo,
|
||||||
string? activeAgentName)
|
AgentIdentity? activeAgent)
|
||||||
{
|
{
|
||||||
if (TryGetHandoffTargetName(command.Pattern, requestInfo, out string handoffAgentName))
|
if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent))
|
||||||
{
|
{
|
||||||
return CreateActivityEvent(
|
return CreateActivityEvent(
|
||||||
command,
|
command,
|
||||||
activityType: "handoff",
|
activityType: "handoff",
|
||||||
agentName: handoffAgentName);
|
agent: handoffAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(activeAgentName) || !TryGetToolName(requestInfo, out string toolName))
|
if (!activeAgent.HasValue || !TryGetToolName(requestInfo, out string toolName))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -162,27 +163,27 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
return CreateActivityEvent(
|
return CreateActivityEvent(
|
||||||
command,
|
command,
|
||||||
activityType: "tool-calling",
|
activityType: "tool-calling",
|
||||||
agentName: activeAgentName,
|
agent: activeAgent.Value,
|
||||||
toolName: toolName);
|
toolName: toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetHandoffTargetName(
|
private static bool TryGetHandoffTarget(
|
||||||
PatternDefinitionDto pattern,
|
PatternDefinitionDto pattern,
|
||||||
RequestInfoEvent requestInfo,
|
RequestInfoEvent requestInfo,
|
||||||
out string agentName)
|
out AgentIdentity agent)
|
||||||
{
|
{
|
||||||
agentName = string.Empty;
|
agent = default;
|
||||||
if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget))
|
if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget);
|
object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget);
|
||||||
agentName = ResolveAgentName(
|
agent = ResolveAgentIdentity(
|
||||||
pattern,
|
pattern,
|
||||||
GetStringProperty(target, "Id"),
|
GetStringProperty(target, "Id"),
|
||||||
GetStringProperty(target, "Name"));
|
GetStringProperty(target, "Name"));
|
||||||
return !string.IsNullOrWhiteSpace(agentName);
|
return !string.IsNullOrWhiteSpace(agent.AgentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetToolName(RequestInfoEvent requestInfo, out string toolName)
|
private static bool TryGetToolName(RequestInfoEvent requestInfo, out string toolName)
|
||||||
@@ -217,12 +218,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryResolveKnownAgentName(
|
private static bool TryResolveKnownAgentIdentity(
|
||||||
PatternDefinitionDto pattern,
|
PatternDefinitionDto pattern,
|
||||||
string? agentIdentifier,
|
string? agentIdentifier,
|
||||||
out string agentName)
|
out AgentIdentity agent)
|
||||||
{
|
{
|
||||||
agentName = string.Empty;
|
agent = default;
|
||||||
if (string.IsNullOrWhiteSpace(agentIdentifier))
|
if (string.IsNullOrWhiteSpace(agentIdentifier))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -236,11 +237,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
agentName = string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name;
|
agent = new AgentIdentity(
|
||||||
|
match.Id,
|
||||||
|
string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveAgentName(
|
private static AgentIdentity ResolveAgentIdentity(
|
||||||
PatternDefinitionDto pattern,
|
PatternDefinitionDto pattern,
|
||||||
string? agentId,
|
string? agentId,
|
||||||
string? agentName)
|
string? agentName)
|
||||||
@@ -250,15 +253,21 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
|
|
||||||
if (match is not null)
|
if (match is not null)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name;
|
return new AgentIdentity(
|
||||||
|
match.Id,
|
||||||
|
string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
|
||||||
|
? agentId
|
||||||
|
: agentName ?? "agent";
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(agentName))
|
if (!string.IsNullOrWhiteSpace(agentName))
|
||||||
{
|
{
|
||||||
return agentName;
|
return new AgentIdentity(resolvedAgentId, agentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return agentId ?? string.Empty;
|
return new AgentIdentity(resolvedAgentId, resolvedAgentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
|
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
RequestId = command.RequestId,
|
RequestId = command.RequestId,
|
||||||
SessionId = command.SessionId,
|
SessionId = command.SessionId,
|
||||||
ActivityType = "thinking",
|
ActivityType = "thinking",
|
||||||
|
AgentId = "agent-1",
|
||||||
AgentName = "Primary",
|
AgentName = "Primary",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
RequestId = command.RequestId,
|
RequestId = command.RequestId,
|
||||||
SessionId = command.SessionId,
|
SessionId = command.SessionId,
|
||||||
ActivityType = "tool-calling",
|
ActivityType = "tool-calling",
|
||||||
|
AgentId = "agent-1",
|
||||||
AgentName = "Primary",
|
AgentName = "Primary",
|
||||||
ToolName = "read_file",
|
ToolName = "read_file",
|
||||||
});
|
});
|
||||||
@@ -179,6 +181,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
Assert.Equal("turn-1", thinkingEvent.GetProperty("requestId").GetString());
|
Assert.Equal("turn-1", thinkingEvent.GetProperty("requestId").GetString());
|
||||||
Assert.Equal("session-1", thinkingEvent.GetProperty("sessionId").GetString());
|
Assert.Equal("session-1", thinkingEvent.GetProperty("sessionId").GetString());
|
||||||
Assert.Equal("thinking", thinkingEvent.GetProperty("activityType").GetString());
|
Assert.Equal("thinking", thinkingEvent.GetProperty("activityType").GetString());
|
||||||
|
Assert.Equal("agent-1", thinkingEvent.GetProperty("agentId").GetString());
|
||||||
Assert.Equal("Primary", thinkingEvent.GetProperty("agentName").GetString());
|
Assert.Equal("Primary", thinkingEvent.GetProperty("agentName").GetString());
|
||||||
},
|
},
|
||||||
deltaEvent =>
|
deltaEvent =>
|
||||||
@@ -190,6 +193,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
{
|
{
|
||||||
Assert.Equal("agent-activity", toolEvent.GetProperty("type").GetString());
|
Assert.Equal("agent-activity", toolEvent.GetProperty("type").GetString());
|
||||||
Assert.Equal("tool-calling", toolEvent.GetProperty("activityType").GetString());
|
Assert.Equal("tool-calling", toolEvent.GetProperty("activityType").GetString());
|
||||||
|
Assert.Equal("agent-1", toolEvent.GetProperty("agentId").GetString());
|
||||||
Assert.Equal("read_file", toolEvent.GetProperty("toolName").GetString());
|
Assert.Equal("read_file", toolEvent.GetProperty("toolName").GetString());
|
||||||
},
|
},
|
||||||
completionEvent =>
|
completionEvent =>
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
kind: 'agent-activity',
|
kind: 'agent-activity',
|
||||||
occurredAt: nowIso(),
|
occurredAt: nowIso(),
|
||||||
activityType: event.activityType,
|
activityType: event.activityType,
|
||||||
|
agentId: event.agentId,
|
||||||
agentName: event.agentName,
|
agentName: event.agentName,
|
||||||
toolName: event.toolName,
|
toolName: event.toolName,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
|
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
|
import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatSessionActivityLabel,
|
buildAgentActivityRows,
|
||||||
shouldAnimateSessionActivity,
|
formatAgentActivityLabel,
|
||||||
|
isAgentActivityActive,
|
||||||
|
isAgentActivityCompleted,
|
||||||
type SessionActivityState,
|
type SessionActivityState,
|
||||||
} from '@renderer/lib/sessionActivity';
|
} from '@renderer/lib/sessionActivity';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
@@ -34,10 +36,10 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const isBusy = session.status === 'running';
|
const isBusy = session.status === 'running';
|
||||||
const hasPendingMessage = session.messages.some((m) => m.pending);
|
const activityRows = useMemo(
|
||||||
const isThinking = isBusy && !hasPendingMessage;
|
() => buildAgentActivityRows(activity, pattern.agents, isBusy),
|
||||||
const activityLabel = formatSessionActivityLabel(activity, pattern.agents[0]?.name ?? 'Agent');
|
[activity, isBusy, pattern.agents],
|
||||||
const showActivityAnimation = shouldAnimateSessionActivity(activity);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
transcriptRef.current?.scrollTo({
|
transcriptRef.current?.scrollTo({
|
||||||
@@ -136,17 +138,31 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activity indicator — shown while the agent is thinking (before streaming starts) */}
|
{isBusy && activityRows.length > 0 && (
|
||||||
{isThinking && (
|
<div className="mb-4 rounded-xl border border-zinc-800 bg-zinc-900/60 px-4 py-3">
|
||||||
<div className="py-3">
|
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-600">
|
||||||
<div className="flex gap-3">
|
Agent activity
|
||||||
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-zinc-800 text-zinc-400">
|
</div>
|
||||||
<Bot className="size-3.5" />
|
<div className="space-y-2.5">
|
||||||
</div>
|
{activityRows.map((row) => (
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-start gap-3" key={row.key}>
|
||||||
<div className="mb-1.5 text-[12px] font-medium text-zinc-500">{activityLabel}</div>
|
<span
|
||||||
{showActivityAnimation && <ThinkingDots />}
|
className={`mt-1.5 size-2 shrink-0 rounded-full ${
|
||||||
</div>
|
isAgentActivityActive(row.activity)
|
||||||
|
? 'animate-pulse bg-blue-400'
|
||||||
|
: isAgentActivityCompleted(row.activity)
|
||||||
|
? 'bg-emerald-400'
|
||||||
|
: 'bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[12px] font-medium text-zinc-300">{row.agentName}</div>
|
||||||
|
<div className="text-[12px] text-zinc-500">
|
||||||
|
{formatAgentActivityLabel(row.activity)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
|
|
||||||
export interface SessionActivityState {
|
export interface AgentActivityState {
|
||||||
sessionId: string;
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
activityType?: SessionEventRecord['activityType'];
|
activityType?: SessionEventRecord['activityType'];
|
||||||
agentName?: string;
|
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionActivityState = Record<string, AgentActivityState>;
|
||||||
export type SessionActivityMap = Record<string, SessionActivityState | undefined>;
|
export type SessionActivityMap = Record<string, SessionActivityState | undefined>;
|
||||||
|
|
||||||
|
export interface AgentActivityRow {
|
||||||
|
key: string;
|
||||||
|
agentName: string;
|
||||||
|
activity?: AgentActivityState;
|
||||||
|
}
|
||||||
|
|
||||||
export function applySessionEventActivity(
|
export function applySessionEventActivity(
|
||||||
current: SessionActivityMap,
|
current: SessionActivityMap,
|
||||||
event: SessionEventRecord,
|
event: SessionEventRecord,
|
||||||
): SessionActivityMap {
|
): SessionActivityMap {
|
||||||
if (event.kind === 'agent-activity') {
|
if (event.kind === 'agent-activity') {
|
||||||
|
const agentKey = resolveAgentKey(event);
|
||||||
|
if (!agentKey) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[event.sessionId]: {
|
[event.sessionId]: {
|
||||||
sessionId: event.sessionId,
|
...(current[event.sessionId] ?? {}),
|
||||||
activityType: event.activityType,
|
[agentKey]: {
|
||||||
agentName: event.agentName,
|
agentId: event.agentId ?? agentKey,
|
||||||
toolName: event.toolName,
|
agentName: event.agentName?.trim() || event.agentId?.trim() || agentKey,
|
||||||
|
activityType: event.activityType,
|
||||||
|
toolName: event.toolName,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -58,27 +74,72 @@ export function pruneSessionActivities(
|
|||||||
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
|
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSessionActivityLabel(
|
export function buildAgentActivityRows(
|
||||||
activity: SessionActivityState | undefined,
|
current: SessionActivityState | undefined,
|
||||||
fallbackAgentName = 'Agent',
|
agents: PatternDefinition['agents'],
|
||||||
): string {
|
isBusy: boolean,
|
||||||
const agentName = activity?.agentName?.trim() || fallbackAgentName;
|
): AgentActivityRow[] {
|
||||||
|
const hasReportedActivity = !!current && Object.keys(current).length > 0;
|
||||||
|
|
||||||
|
return agents.map((agent, index) => {
|
||||||
|
const activity = current?.[agent.id] ?? current?.[agent.name];
|
||||||
|
|
||||||
|
if (activity) {
|
||||||
|
return {
|
||||||
|
key: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
activity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasReportedActivity && isBusy && index === 0) {
|
||||||
|
return {
|
||||||
|
key: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
activity: {
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
activityType: 'thinking',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAgentActivityLabel(activity: AgentActivityState | undefined): string {
|
||||||
|
if (!activity) {
|
||||||
|
return 'Waiting…';
|
||||||
|
}
|
||||||
|
|
||||||
switch (activity?.activityType) {
|
switch (activity?.activityType) {
|
||||||
case 'tool-calling':
|
case 'tool-calling':
|
||||||
return `${agentName} is using ${activity.toolName?.trim() || 'a tool'}…`;
|
return `Using ${activity.toolName?.trim() || 'a tool'}…`;
|
||||||
case 'handoff':
|
case 'handoff':
|
||||||
return `Handing off to ${agentName}…`;
|
return 'Handling handoff…';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return `${agentName} completed their turn.`;
|
return 'Completed';
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
|
return 'Thinking…';
|
||||||
default:
|
default:
|
||||||
return `${agentName} is thinking…`;
|
return 'Waiting…';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldAnimateSessionActivity(activity: SessionActivityState | undefined): boolean {
|
export function isAgentActivityActive(activity: AgentActivityState | undefined): boolean {
|
||||||
return activity?.activityType !== 'completed';
|
return (
|
||||||
|
activity?.activityType === 'thinking'
|
||||||
|
|| activity?.activityType === 'tool-calling'
|
||||||
|
|| activity?.activityType === 'handoff'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentActivityCompleted(activity: AgentActivityState | undefined): boolean {
|
||||||
|
return activity?.activityType === 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSessionActivity(
|
function removeSessionActivity(
|
||||||
@@ -93,3 +154,7 @@ function removeSessionActivity(
|
|||||||
delete next[sessionId];
|
delete next[sessionId];
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAgentKey(event: SessionEventRecord): string | undefined {
|
||||||
|
return event.agentId?.trim() || event.agentName?.trim();
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface AgentActivityEvent {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
activityType: AgentActivityType;
|
activityType: AgentActivityType;
|
||||||
|
agentId?: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface SessionEventRecord {
|
|||||||
authorName?: string;
|
authorName?: string;
|
||||||
contentDelta?: string;
|
contentDelta?: string;
|
||||||
activityType?: SessionActivityType;
|
activityType?: SessionActivityType;
|
||||||
|
agentId?: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -2,30 +2,83 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applySessionEventActivity,
|
applySessionEventActivity,
|
||||||
formatSessionActivityLabel,
|
buildAgentActivityRows,
|
||||||
|
formatAgentActivityLabel,
|
||||||
|
isAgentActivityActive,
|
||||||
|
isAgentActivityCompleted,
|
||||||
pruneSessionActivities,
|
pruneSessionActivities,
|
||||||
shouldAnimateSessionActivity,
|
|
||||||
type SessionActivityMap,
|
type SessionActivityMap,
|
||||||
} from '@renderer/lib/sessionActivity';
|
} from '@renderer/lib/sessionActivity';
|
||||||
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
|
|
||||||
describe('session activity helpers', () => {
|
describe('session activity helpers', () => {
|
||||||
test('stores the latest agent activity by session', () => {
|
const agents: PatternDefinition['agents'] = [
|
||||||
const event: SessionEventRecord = {
|
{
|
||||||
|
id: 'architect',
|
||||||
|
name: 'Architect',
|
||||||
|
description: 'Designs the system.',
|
||||||
|
instructions: 'Think about architecture.',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reviewer',
|
||||||
|
name: 'Reviewer',
|
||||||
|
description: 'Reviews the solution.',
|
||||||
|
instructions: 'Review the work.',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('stores activity per session and per agent', () => {
|
||||||
|
const architectEvent: SessionEventRecord = {
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
kind: 'agent-activity',
|
kind: 'agent-activity',
|
||||||
occurredAt: '2026-03-23T00:00:00.000Z',
|
occurredAt: '2026-03-23T00:00:00.000Z',
|
||||||
|
activityType: 'thinking',
|
||||||
|
agentId: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
|
};
|
||||||
|
const reviewerEvent: SessionEventRecord = {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'agent-activity',
|
||||||
|
occurredAt: '2026-03-23T00:00:01.000Z',
|
||||||
activityType: 'tool-calling',
|
activityType: 'tool-calling',
|
||||||
agentName: 'Code Reviewer',
|
agentId: 'reviewer',
|
||||||
|
agentName: 'Reviewer',
|
||||||
toolName: 'read_file',
|
toolName: 'read_file',
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(applySessionEventActivity({}, event)).toEqual({
|
expect(applySessionEventActivity({}, architectEvent)).toEqual({
|
||||||
'session-1': {
|
'session-1': {
|
||||||
sessionId: 'session-1',
|
architect: {
|
||||||
activityType: 'tool-calling',
|
agentId: 'architect',
|
||||||
agentName: 'Code Reviewer',
|
agentName: 'Architect',
|
||||||
toolName: 'read_file',
|
activityType: 'thinking',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applySessionEventActivity(
|
||||||
|
applySessionEventActivity({}, architectEvent),
|
||||||
|
reviewerEvent,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
'session-1': {
|
||||||
|
architect: {
|
||||||
|
agentId: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
|
activityType: 'thinking',
|
||||||
|
},
|
||||||
|
reviewer: {
|
||||||
|
agentId: 'reviewer',
|
||||||
|
agentName: 'Reviewer',
|
||||||
|
activityType: 'tool-calling',
|
||||||
|
toolName: 'read_file',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -33,14 +86,18 @@ describe('session activity helpers', () => {
|
|||||||
test('clears stale activity when a session restarts or finishes', () => {
|
test('clears stale activity when a session restarts or finishes', () => {
|
||||||
const current: SessionActivityMap = {
|
const current: SessionActivityMap = {
|
||||||
'session-1': {
|
'session-1': {
|
||||||
sessionId: 'session-1',
|
architect: {
|
||||||
activityType: 'thinking',
|
agentId: 'architect',
|
||||||
agentName: 'Primary',
|
agentName: 'Architect',
|
||||||
|
activityType: 'thinking',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'session-2': {
|
'session-2': {
|
||||||
sessionId: 'session-2',
|
reviewer: {
|
||||||
activityType: 'handoff',
|
agentId: 'reviewer',
|
||||||
agentName: 'Reviewer',
|
agentName: 'Reviewer',
|
||||||
|
activityType: 'handoff',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,63 +124,121 @@ describe('session activity helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formats contextual activity labels and animation state', () => {
|
test('builds rows for all agents with sensible defaults', () => {
|
||||||
expect(formatSessionActivityLabel(undefined, 'Primary')).toBe('Primary is thinking…');
|
expect(buildAgentActivityRows(undefined, agents, true)).toEqual([
|
||||||
|
{
|
||||||
|
key: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
|
activity: {
|
||||||
|
agentId: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
|
activityType: 'thinking',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reviewer',
|
||||||
|
agentName: 'Reviewer',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
formatSessionActivityLabel(
|
buildAgentActivityRows(
|
||||||
{
|
{
|
||||||
sessionId: 'session-1',
|
architect: {
|
||||||
activityType: 'tool-calling',
|
agentId: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
|
activityType: 'completed',
|
||||||
|
},
|
||||||
|
reviewer: {
|
||||||
|
agentId: 'reviewer',
|
||||||
|
agentName: 'Reviewer',
|
||||||
|
activityType: 'tool-calling',
|
||||||
|
toolName: 'read_file',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
key: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
|
activity: {
|
||||||
|
agentId: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
|
activityType: 'completed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reviewer',
|
||||||
|
agentName: 'Reviewer',
|
||||||
|
activity: {
|
||||||
|
agentId: 'reviewer',
|
||||||
agentName: 'Reviewer',
|
agentName: 'Reviewer',
|
||||||
|
activityType: 'tool-calling',
|
||||||
toolName: 'read_file',
|
toolName: 'read_file',
|
||||||
},
|
},
|
||||||
'Primary',
|
},
|
||||||
),
|
]);
|
||||||
).toBe('Reviewer is using read_file…');
|
});
|
||||||
|
|
||||||
|
test('formats contextual activity labels and state flags', () => {
|
||||||
|
expect(formatAgentActivityLabel(undefined)).toBe('Waiting…');
|
||||||
expect(
|
expect(
|
||||||
formatSessionActivityLabel(
|
formatAgentActivityLabel({
|
||||||
{
|
agentId: 'reviewer',
|
||||||
sessionId: 'session-1',
|
agentName: 'Reviewer',
|
||||||
activityType: 'handoff',
|
activityType: 'tool-calling',
|
||||||
agentName: 'Summarizer',
|
toolName: 'read_file',
|
||||||
},
|
}),
|
||||||
'Primary',
|
).toBe('Using read_file…');
|
||||||
),
|
|
||||||
).toBe('Handing off to Summarizer…');
|
|
||||||
expect(
|
expect(
|
||||||
formatSessionActivityLabel(
|
formatAgentActivityLabel({
|
||||||
{
|
agentId: 'reviewer',
|
||||||
sessionId: 'session-1',
|
agentName: 'Reviewer',
|
||||||
activityType: 'completed',
|
activityType: 'handoff',
|
||||||
agentName: 'Reviewer',
|
}),
|
||||||
},
|
).toBe('Handling handoff…');
|
||||||
'Primary',
|
|
||||||
),
|
|
||||||
).toBe('Reviewer completed their turn.');
|
|
||||||
expect(
|
expect(
|
||||||
shouldAnimateSessionActivity({
|
formatAgentActivityLabel({
|
||||||
sessionId: 'session-1',
|
agentId: 'reviewer',
|
||||||
|
agentName: 'Reviewer',
|
||||||
|
activityType: 'completed',
|
||||||
|
}),
|
||||||
|
).toBe('Completed');
|
||||||
|
expect(
|
||||||
|
isAgentActivityActive({
|
||||||
|
agentId: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
activityType: 'thinking',
|
activityType: 'thinking',
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
shouldAnimateSessionActivity({
|
isAgentActivityCompleted({
|
||||||
sessionId: 'session-1',
|
agentId: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
activityType: 'completed',
|
activityType: 'completed',
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
|
expect(isAgentActivityCompleted(undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prunes activity state for sessions that no longer exist', () => {
|
test('prunes activity state for sessions that no longer exist', () => {
|
||||||
const current: SessionActivityMap = {
|
const current: SessionActivityMap = {
|
||||||
'session-1': {
|
'session-1': {
|
||||||
sessionId: 'session-1',
|
architect: {
|
||||||
activityType: 'thinking',
|
agentId: 'architect',
|
||||||
|
agentName: 'Architect',
|
||||||
|
activityType: 'thinking',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'session-2': {
|
'session-2': {
|
||||||
sessionId: 'session-2',
|
reviewer: {
|
||||||
activityType: 'tool-calling',
|
agentId: 'reviewer',
|
||||||
toolName: 'read_file',
|
agentName: 'Reviewer',
|
||||||
|
activityType: 'tool-calling',
|
||||||
|
toolName: 'read_file',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user