mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 03:38:44 +02:00
feat: add agent activity events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user