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
+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;
}