feat: add agent activity events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-21 12:48:27 +01:00
co-authored by Copilot
parent b9e9662f6e
commit c5979d07bd
14 changed files with 754 additions and 33 deletions
+15 -1
View File
@@ -3,7 +3,7 @@ import { basename } from 'node:path';
import { dialog } from 'electron';
import type { TurnDeltaEvent } from '@shared/contracts/sidecar';
import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar';
import { buildSessionTitle, validatePatternDefinition, type PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionEventRecord } from '@shared/domain/event';
@@ -199,6 +199,9 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
async (event) => {
await this.applyTurnDelta(workspace, session.id, event);
},
(event) => {
this.emitAgentActivity(event);
},
);
this.finalizeTurn(workspace, session.id, responseMessages);
@@ -301,6 +304,17 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
});
}
private emitAgentActivity(event: AgentActivityEvent): void {
this.emitSessionEvent({
sessionId: event.sessionId,
kind: 'agent-activity',
occurredAt: nowIso(),
activityType: event.activityType,
agentName: event.agentName,
toolName: event.toolName,
});
}
private finalizeTurn(workspace: WorkspaceState, sessionId: string, messages: ChatMessageRecord[]): void {
const session = this.requireSession(workspace, sessionId);
const incomingIds = new Set(messages.map((message) => message.id));
+29 -5
View File
@@ -2,6 +2,7 @@ import { app } from 'electron';
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import type {
AgentActivityEvent,
SidecarCapabilities,
SidecarCommand,
SidecarEvent,
@@ -28,7 +29,8 @@ type PendingCommand =
kind: 'run-turn';
resolve: (messages: ChatMessageRecord[]) => void;
reject: (error: Error) => void;
onDelta: (event: TurnDeltaEvent) => void;
onDelta: (event: TurnDeltaEvent) => void | Promise<void>;
onActivity: (event: AgentActivityEvent) => void | Promise<void>;
};
export class SidecarClient {
@@ -53,8 +55,12 @@ export class SidecarClient {
});
}
async runTurn(command: RunTurnCommand, onDelta: (event: TurnDeltaEvent) => void): Promise<ChatMessageRecord[]> {
return this.dispatch<ChatMessageRecord[]>(command, onDelta);
async runTurn(
command: RunTurnCommand,
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
): Promise<ChatMessageRecord[]> {
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity);
}
async dispose(): Promise<void> {
@@ -110,7 +116,8 @@ export class SidecarClient {
private async dispatch<TResult>(
command: SidecarCommand,
onDelta?: (event: TurnDeltaEvent) => void,
onDelta?: (event: TurnDeltaEvent) => void | Promise<void>,
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
): Promise<TResult> {
const process = await this.ensureProcess();
@@ -121,6 +128,7 @@ export class SidecarClient {
resolve: resolve as (messages: ChatMessageRecord[]) => void,
reject,
onDelta: onDelta ?? (() => undefined),
onActivity: onActivity ?? (() => undefined),
});
} else if (command.type === 'validate-pattern') {
this.pending.set(command.requestId, {
@@ -176,7 +184,12 @@ export class SidecarClient {
return;
case 'turn-delta':
if (pending.kind === 'run-turn') {
pending.onDelta(event);
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onDelta(event));
}
return;
case 'agent-activity':
if (pending.kind === 'run-turn') {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onActivity(event));
}
return;
case 'turn-complete':
@@ -196,4 +209,15 @@ export class SidecarClient {
return;
}
}
private invokeRunTurnHandler(
requestId: string,
pending: Extract<PendingCommand, { kind: 'run-turn' }>,
callback: () => void | Promise<void>,
): void {
void Promise.resolve(callback()).catch((error: unknown) => {
this.pending.delete(requestId);
pending.reject(error instanceof Error ? error : new Error(String(error)));
});
}
}
+22
View File
@@ -5,6 +5,11 @@ import { ChatPane } from '@renderer/components/ChatPane';
import { NewSessionModal } from '@renderer/components/NewSessionModal';
import { SettingsPanel } from '@renderer/components/SettingsPanel';
import { Sidebar } from '@renderer/components/Sidebar';
import {
applySessionEventActivity,
pruneSessionActivities,
type SessionActivityMap,
} from '@renderer/lib/sessionActivity';
import { WelcomePane } from '@renderer/components/WelcomePane';
import { getElectronApi } from '@renderer/lib/electronApi';
import type { PatternDefinition } from '@shared/domain/pattern';
@@ -39,6 +44,7 @@ export default function App() {
const api = getElectronApi();
const [workspace, setWorkspace] = useState<WorkspaceState>();
const [error, setError] = useState<string>();
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
const [showSettings, setShowSettings] = useState(false);
const [showNewSession, setShowNewSession] = useState(false);
@@ -55,11 +61,22 @@ export default function App() {
const offWorkspace = api.onWorkspaceUpdated((ws) => {
setWorkspace(ws);
setError(undefined);
setSessionActivities((current) =>
pruneSessionActivities(
current,
ws.sessions.map((session) => session.id),
),
);
});
const offSessionEvent = api.onSessionEvent((event) => {
setSessionActivities((current) => applySessionEventActivity(current, event));
});
return () => {
disposed = true;
offWorkspace();
offSessionEvent();
};
}, [api]);
@@ -82,6 +99,10 @@ export default function App() {
: undefined,
[selectedSession, workspace?.projects],
);
const activityForSession = useMemo(
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
[selectedSession, sessionActivities],
);
// Loading state
if (!workspace) {
@@ -106,6 +127,7 @@ export default function App() {
} else if (selectedSession && patternForSession && projectForSession) {
content = (
<ChatPane
activity={activityForSession}
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
pattern={patternForSession}
project={projectForSession}
+11 -5
View File
@@ -1,6 +1,11 @@
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
import {
formatSessionActivityLabel,
shouldAnimateSessionActivity,
type SessionActivityState,
} from '@renderer/lib/sessionActivity';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
@@ -16,13 +21,14 @@ function ThinkingDots() {
}
interface ChatPaneProps {
activity?: SessionActivityState;
project: ProjectRecord;
pattern: PatternDefinition;
session: SessionRecord;
onSend: (content: string) => Promise<void>;
}
export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
export function ChatPane({ activity, project, pattern, session, onSend }: ChatPaneProps) {
const [input, setInput] = useState('');
const transcriptRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -30,6 +36,8 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
const isBusy = session.status === 'running';
const hasPendingMessage = session.messages.some((m) => m.pending);
const isThinking = isBusy && !hasPendingMessage;
const activityLabel = formatSessionActivityLabel(activity, pattern.agents[0]?.name ?? 'Agent');
const showActivityAnimation = shouldAnimateSessionActivity(activity);
useEffect(() => {
transcriptRef.current?.scrollTo({
@@ -136,10 +144,8 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
<Bot className="size-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1.5 text-[12px] font-medium text-zinc-500">
{pattern.agents[0]?.name ?? 'Agent'}
</div>
<ThinkingDots />
<div className="mb-1.5 text-[12px] font-medium text-zinc-500">{activityLabel}</div>
{showActivityAnimation && <ThinkingDots />}
</div>
</div>
</div>
+95
View File
@@ -0,0 +1,95 @@
import type { SessionEventRecord } from '@shared/domain/event';
export interface SessionActivityState {
sessionId: string;
activityType?: SessionEventRecord['activityType'];
agentName?: string;
toolName?: string;
}
export type SessionActivityMap = Record<string, SessionActivityState | undefined>;
export function applySessionEventActivity(
current: SessionActivityMap,
event: SessionEventRecord,
): SessionActivityMap {
if (event.kind === 'agent-activity') {
return {
...current,
[event.sessionId]: {
sessionId: event.sessionId,
activityType: event.activityType,
agentName: event.agentName,
toolName: event.toolName,
},
};
}
if (event.kind === 'status') {
if (event.status === 'running' || event.status === 'idle' || event.status === 'error') {
return removeSessionActivity(current, event.sessionId);
}
}
if (event.kind === 'error') {
return removeSessionActivity(current, event.sessionId);
}
return current;
}
export function pruneSessionActivities(
current: SessionActivityMap,
sessionIds: Iterable<string>,
): SessionActivityMap {
const allowed = new Set(sessionIds);
const next: SessionActivityMap = {};
let changed = false;
for (const [sessionId, activity] of Object.entries(current)) {
if (!allowed.has(sessionId)) {
changed = true;
continue;
}
next[sessionId] = activity;
}
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
}
export function formatSessionActivityLabel(
activity: SessionActivityState | undefined,
fallbackAgentName = 'Agent',
): string {
const agentName = activity?.agentName?.trim() || fallbackAgentName;
switch (activity?.activityType) {
case 'tool-calling':
return `${agentName} is using ${activity.toolName?.trim() || 'a tool'}`;
case 'handoff':
return `Handing off to ${agentName}`;
case 'completed':
return `${agentName} completed their turn.`;
case 'thinking':
default:
return `${agentName} is thinking…`;
}
}
export function shouldAnimateSessionActivity(activity: SessionActivityState | undefined): boolean {
return activity?.activityType !== 'completed';
}
function removeSessionActivity(
current: SessionActivityMap,
sessionId: string,
): SessionActivityMap {
if (!(sessionId in current)) {
return current;
}
const next = { ...current };
delete next[sessionId];
return next;
}
+12
View File
@@ -61,6 +61,17 @@ export interface TurnCompleteEvent {
messages: ChatMessageRecord[];
}
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
export interface AgentActivityEvent {
type: 'agent-activity';
requestId: string;
sessionId: string;
activityType: AgentActivityType;
agentName?: string;
toolName?: string;
}
export interface CommandErrorEvent {
type: 'command-error';
requestId: string;
@@ -77,5 +88,6 @@ export type SidecarEvent =
| PatternValidationEvent
| TurnDeltaEvent
| TurnCompleteEvent
| AgentActivityEvent
| CommandErrorEvent
| CommandCompleteEvent;
+11 -1
View File
@@ -1,4 +1,11 @@
export type SessionEventKind = 'status' | 'message-delta' | 'message-complete' | 'error';
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
export type SessionEventKind =
| 'status'
| 'message-delta'
| 'message-complete'
| 'agent-activity'
| 'error';
export interface SessionEventRecord {
sessionId: string;
@@ -8,5 +15,8 @@ export interface SessionEventRecord {
messageId?: string;
authorName?: string;
contentDelta?: string;
activityType?: SessionActivityType;
agentName?: string;
toolName?: string;
error?: string;
}