diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx new file mode 100644 index 000000000..b2a100085 --- /dev/null +++ b/packages/app/src/components/agent-activity.tsx @@ -0,0 +1,439 @@ +import { useState } from 'react'; +import { View, Text, Pressable } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import type { AgentActivity, GroupedTextMessage, SessionUpdate } from '@/types/agent-activity'; + +interface AgentActivityItemProps { + item: GroupedTextMessage | AgentActivity; +} + +function formatTimestamp(date: Date): string { + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + }).format(date); +} + +function getToolIcon(toolKind?: string): string { + switch (toolKind) { + case 'read': + return '📖'; + case 'edit': + return '✏️'; + case 'delete': + return '🗑️'; + case 'move': + return '📦'; + case 'search': + return '🔍'; + case 'execute': + return '▶️'; + case 'think': + return '💭'; + case 'fetch': + return '🌐'; + case 'switch_mode': + return '🔄'; + default: + return '🔧'; + } +} + +function getStatusColor(status?: string): string { + switch (status) { + case 'pending': + return '#9ca3af'; + case 'in_progress': + return '#fbbf24'; + case 'completed': + return '#22c55e'; + case 'failed': + return '#ef4444'; + default: + return '#6b7280'; + } +} + +function GroupedTextItem({ item }: { item: GroupedTextMessage }) { + const isThought = item.messageType === 'thought'; + + return ( + + + {formatTimestamp(item.startTimestamp)} + + {isThought && ( + 💭 Thinking + )} + + {item.text} + + + ); +} + +function ToolCallItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) { + const [isExpanded, setIsExpanded] = useState(false); + + if (update.kind !== 'tool_call' && update.kind !== 'tool_call_update') { + return null; + } + + const isUpdate = update.kind === 'tool_call_update'; + const title = update.title || 'Tool Call'; + const status = update.status; + const toolKind = update.toolKind; + + return ( + + setIsExpanded(!isExpanded)} + style={stylesheet.toolHeader} + > + + + {formatTimestamp(timestamp)} + + + {getToolIcon(toolKind)} + {title} + {status && ( + + {status} + + )} + + + {isExpanded ? '▼' : '▶'} + + + {isExpanded && ( + + {update.rawInput && ( + + Input: + + {JSON.stringify(update.rawInput, null, 2)} + + + )} + {update.rawOutput && ( + + Output: + + {JSON.stringify(update.rawOutput, null, 2)} + + + )} + {!update.rawInput && !update.rawOutput && ( + + {isUpdate ? 'Tool call updated' : 'No details available'} + + )} + + )} + + ); +} + +function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) { + const [isExpanded, setIsExpanded] = useState(true); + + if (update.kind !== 'plan') { + return null; + } + + return ( + + setIsExpanded(!isExpanded)} + style={stylesheet.planHeader} + > + + {formatTimestamp(timestamp)} + + 📋 Plan ({update.entries.length} tasks) + + + {isExpanded ? '▼' : '▶'} + + + {isExpanded && ( + + {update.entries.map((entry, idx) => ( + + + {entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'} + + {entry.content} + + ))} + + )} + + ); +} + +function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) { + const [showDrawer, setShowDrawer] = useState(false); + + return ( + + setShowDrawer(!showDrawer)} + style={stylesheet.unknownHeader} + > + {formatTimestamp(timestamp)} + + {update.kind} + + + + {showDrawer && ( + + + {JSON.stringify(update, null, 2)} + + + )} + + ); +} + +export function AgentActivityItem({ item }: AgentActivityItemProps) { + // Grouped text message + if ('kind' in item && item.kind === 'grouped_text') { + return ; + } + + // Individual activity + const activity = item as AgentActivity; + const update = activity.update; + + // Tool calls + if (update.kind === 'tool_call' || update.kind === 'tool_call_update') { + return ; + } + + // Plan + if (update.kind === 'plan') { + return ; + } + + // Available commands update + if (update.kind === 'available_commands_update') { + return ( + + {formatTimestamp(activity.timestamp)} + + Commands updated ({update.availableCommands.length} available) + + + ); + } + + // Current mode update + if (update.kind === 'current_mode_update') { + return ( + + {formatTimestamp(activity.timestamp)} + + Mode changed to: {update.currentModeId} + + + ); + } + + // Unknown activity type + return ; +} + +const stylesheet = StyleSheet.create((theme) => ({ + card: { + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + padding: theme.spacing[3], + marginBottom: theme.spacing[2], + }, + thoughtCard: { + backgroundColor: theme.colors.muted, + borderLeftWidth: 3, + borderLeftColor: theme.colors.palette.purple[500], + }, + timestamp: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + marginBottom: theme.spacing[1], + }, + thoughtLabel: { + color: theme.colors.palette.purple[400], + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + marginBottom: theme.spacing[1], + }, + text: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, + thoughtText: { + color: theme.colors.mutedForeground, + fontStyle: 'italic', + }, + toolCard: { + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[2], + overflow: 'hidden', + }, + toolHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing[3], + }, + toolHeaderLeft: { + flex: 1, + }, + toolTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing[2], + marginTop: theme.spacing[1], + }, + toolIcon: { + fontSize: theme.fontSize.base, + }, + toolTitle: { + color: theme.colors.palette.blue[400], + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + flex: 1, + }, + statusBadge: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + }, + statusText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, + expandIcon: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, + toolContent: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + padding: theme.spacing[3], + }, + section: { + marginBottom: theme.spacing[3], + }, + sectionTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + marginBottom: theme.spacing[1], + }, + code: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + fontFamily: 'monospace', + backgroundColor: theme.colors.muted, + padding: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, + emptyText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + fontStyle: 'italic', + }, + planCard: { + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[2], + overflow: 'hidden', + }, + planHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing[3], + }, + planHeaderLeft: { + flex: 1, + }, + planTitle: { + color: theme.colors.palette.green[400], + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + marginTop: theme.spacing[1], + }, + planContent: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + padding: theme.spacing[3], + }, + planEntry: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: theme.spacing[2], + marginBottom: theme.spacing[2], + }, + planEntryStatus: { + fontSize: theme.fontSize.sm, + marginTop: 2, + }, + planEntryText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + flex: 1, + }, + infoText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + }, + unknownCard: { + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[2], + overflow: 'hidden', + }, + unknownHeader: { + padding: theme.spacing[3], + }, + unknownBadge: { + backgroundColor: theme.colors.palette.orange[600], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + marginTop: theme.spacing[2], + alignSelf: 'flex-start', + }, + unknownBadgeText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, + drawerContent: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + padding: theme.spacing[3], + backgroundColor: theme.colors.muted, + }, +})); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index f8542f938..f818d80e7 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect, useRef } from 'react'; +import { useEffect, useRef, useMemo } from 'react'; import { View, Text, ScrollView, Pressable } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StyleSheet } from 'react-native-unistyles'; import type { AgentStatus } from '@server/server/acp/types'; import type { SessionNotification } from '@agentclientprotocol/sdk'; +import { AgentActivityItem } from './agent-activity'; +import { parseSessionUpdate, groupTextChunks, type AgentActivity } from '@/types/agent-activity'; export interface AgentStreamViewProps { agentId: string; @@ -66,125 +68,6 @@ function formatTimestamp(date: Date): string { }).format(date); } -function AgentUpdate({ - update, -}: { - update: { timestamp: Date; notification: SessionNotification }; -}) { - const [isExpanded, setIsExpanded] = useState(true); - const notification = update.notification as any; - - // Parse different notification types - if (notification.type === 'sessionUpdate') { - const sessionUpdate = notification.sessionUpdate || {}; - - // Handle message chunks - if (sessionUpdate.kind === 'agent_message_chunk') { - return ( - - - {formatTimestamp(update.timestamp)} - - {sessionUpdate.chunk || ''} - - ); - } - - // Handle tool calls - if (sessionUpdate.kind === 'tool_call') { - return ( - - setIsExpanded(!isExpanded)} - style={stylesheet.collapsibleHeader} - > - - {formatTimestamp(update.timestamp)} - - - Tool Call: {sessionUpdate.toolName || 'unknown'} - - - {isExpanded && sessionUpdate.arguments && ( - - - {JSON.stringify(sessionUpdate.arguments, null, 2)} - - - )} - - ); - } - - // Handle tool call updates - if (sessionUpdate.kind === 'tool_call_update') { - return ( - - - {formatTimestamp(update.timestamp)} - - - {sessionUpdate.status || 'updating'} - - - ); - } - - // Handle available commands - if (sessionUpdate.kind === 'available_commands_update') { - const commands = sessionUpdate.commands || []; - return ( - - setIsExpanded(!isExpanded)} - style={stylesheet.collapsibleHeader} - > - - {formatTimestamp(update.timestamp)} - - - Available Commands ({commands.length}) - - - {isExpanded && ( - - {commands.map((cmd: any, idx: number) => ( - - • {cmd.name || cmd} - - ))} - - )} - - ); - } - - // Generic session update - return ( - - - {formatTimestamp(update.timestamp)} - - - {JSON.stringify(sessionUpdate, null, 2)} - - - ); - } - - // Fallback for unknown notification types - return ( - - - {formatTimestamp(update.timestamp)} - - - {JSON.stringify(notification, null, 2)} - - - ); -} - export function AgentStreamView({ agentId, agent, @@ -196,10 +79,29 @@ export function AgentStreamView({ const scrollViewRef = useRef(null); const insets = useSafeAreaInsets(); + // Parse and group activities + const groupedActivities = useMemo(() => { + // Parse all notifications into typed activities + const activities: AgentActivity[] = updates + .map((update) => { + const parsedUpdate = parseSessionUpdate(update.notification); + if (!parsedUpdate) return null; + + return { + timestamp: update.timestamp, + update: parsedUpdate, + }; + }) + .filter((activity): activity is AgentActivity => activity !== null); + + // Group consecutive text chunks + return groupTextChunks(activities); + }, [updates]); + // Auto-scroll to bottom when new updates arrive useEffect(() => { scrollViewRef.current?.scrollToEnd({ animated: true }); - }, [updates]); + }, [groupedActivities]); const canCancel = agent.status === 'processing'; const canKill = agent.status !== 'killed' && agent.status !== 'completed'; @@ -274,14 +176,16 @@ export function AgentStreamView({ style={stylesheet.scrollView} contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }} > - {updates.length === 0 ? ( + {groupedActivities.length === 0 ? ( No updates yet.{'\n'}Waiting for agent activity... ) : ( - updates.map((update, idx) => ) + groupedActivities.map((item, idx) => ( + + )) )} @@ -394,60 +298,4 @@ const stylesheet = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, textAlign: "center", }, - updateCard: { - backgroundColor: theme.colors.card, - borderRadius: theme.borderRadius.lg, - padding: theme.spacing[3], - marginBottom: theme.spacing[2], - }, - collapsibleCard: { - backgroundColor: theme.colors.card, - borderRadius: theme.borderRadius.lg, - marginBottom: theme.spacing[2], - overflow: "hidden", - }, - collapsibleHeader: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - collapsibleContent: { - paddingHorizontal: theme.spacing[3], - paddingBottom: theme.spacing[3], - borderTopWidth: theme.borderWidth[1], - borderTopColor: theme.colors.border, - }, - timestampText: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - marginBottom: theme.spacing[1], - }, - messageText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - toolCallText: { - color: theme.colors.palette.blue[400], - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - updateStatusText: { - color: theme.colors.palette.yellow[400], - fontSize: theme.fontSize.sm, - }, - commandsText: { - color: theme.colors.palette.green[400], - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - commandItem: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - marginTop: theme.spacing[1], - }, - codeText: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - fontFamily: "monospace", - marginTop: theme.spacing[2], - }, })); diff --git a/packages/app/src/types/agent-activity.ts b/packages/app/src/types/agent-activity.ts new file mode 100644 index 000000000..90c9be6e1 --- /dev/null +++ b/packages/app/src/types/agent-activity.ts @@ -0,0 +1,267 @@ +import type { SessionNotification } from '@agentclientprotocol/sdk'; + +/** + * Discriminated union types for session updates + */ +export type SessionUpdate = + | UserMessageChunk + | AgentMessageChunk + | AgentThoughtChunk + | ToolCall + | ToolCallUpdate + | Plan + | AvailableCommandsUpdate + | CurrentModeUpdate; + +export interface UserMessageChunk { + kind: 'user_message_chunk'; + content: { + type: 'text'; + text: string; + }; +} + +export interface AgentMessageChunk { + kind: 'agent_message_chunk'; + content: { + type: 'text'; + text: string; + }; +} + +export interface AgentThoughtChunk { + kind: 'agent_thought_chunk'; + content: { + type: 'text'; + text: string; + }; +} + +export interface ToolCall { + kind: 'tool_call'; + toolCallId: string; + title: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other'; + rawInput?: Record; + rawOutput?: Record; + content?: unknown[]; + locations?: unknown[]; +} + +export interface ToolCallUpdate { + kind: 'tool_call_update'; + toolCallId: string; + title?: string | null; + status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null; + toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other' | null; + rawInput?: Record; + rawOutput?: Record; + content?: unknown[] | null; + locations?: unknown[] | null; +} + +export interface Plan { + kind: 'plan'; + entries: Array<{ + content: string; + status: 'pending' | 'in_progress' | 'completed'; + priority: 'high' | 'medium' | 'low'; + }>; +} + +export interface AvailableCommandsUpdate { + kind: 'available_commands_update'; + availableCommands: Array<{ + name: string; + description: string; + }>; +} + +export interface CurrentModeUpdate { + kind: 'current_mode_update'; + currentModeId: string; +} + +/** + * Activity item with timestamp + */ +export interface AgentActivity { + timestamp: Date; + update: SessionUpdate; +} + +/** + * Grouped text message (consecutive chunks combined) + */ +export interface GroupedTextMessage { + kind: 'grouped_text'; + messageType: 'user' | 'agent' | 'thought'; + text: string; + startTimestamp: Date; + endTimestamp: Date; +} + +/** + * Parse SessionNotification into typed SessionUpdate + */ +export function parseSessionUpdate(notification: SessionNotification): SessionUpdate | null { + const update = (notification as any).update; + + if (!update || !update.sessionUpdate) { + return null; + } + + const kind = update.sessionUpdate; + + switch (kind) { + case 'user_message_chunk': + return { + kind: 'user_message_chunk', + content: update.content, + }; + + case 'agent_message_chunk': + return { + kind: 'agent_message_chunk', + content: update.content, + }; + + case 'agent_thought_chunk': + return { + kind: 'agent_thought_chunk', + content: update.content, + }; + + case 'tool_call': + return { + kind: 'tool_call', + toolCallId: update.toolCallId, + title: update.title, + status: update.status, + toolKind: update.kind, + rawInput: update.rawInput, + rawOutput: update.rawOutput, + content: update.content, + locations: update.locations, + }; + + case 'tool_call_update': + return { + kind: 'tool_call_update', + toolCallId: update.toolCallId, + title: update.title, + status: update.status, + toolKind: update.kind, + rawInput: update.rawInput, + rawOutput: update.rawOutput, + content: update.content, + locations: update.locations, + }; + + case 'plan': + return { + kind: 'plan', + entries: update.entries, + }; + + case 'available_commands_update': + return { + kind: 'available_commands_update', + availableCommands: update.availableCommands, + }; + + case 'current_mode_update': + return { + kind: 'current_mode_update', + currentModeId: update.currentModeId, + }; + + default: + return null; + } +} + +/** + * Group consecutive text chunks into messages + */ +export function groupTextChunks(activities: AgentActivity[]): Array { + const result: Array = []; + let currentGroup: { + messageType: 'user' | 'agent' | 'thought'; + chunks: string[]; + startTimestamp: Date; + endTimestamp: Date; + } | null = null; + + for (const activity of activities) { + const update = activity.update; + + // Check if this is a text chunk + if ( + update.kind === 'user_message_chunk' || + update.kind === 'agent_message_chunk' || + update.kind === 'agent_thought_chunk' + ) { + const messageType = + update.kind === 'user_message_chunk' ? 'user' : + update.kind === 'agent_message_chunk' ? 'agent' : + 'thought'; + + const text = update.content.text; + + // If we have a current group of the same type, add to it + if (currentGroup && currentGroup.messageType === messageType) { + currentGroup.chunks.push(text); + currentGroup.endTimestamp = activity.timestamp; + } else { + // Flush current group if exists + if (currentGroup) { + result.push({ + kind: 'grouped_text', + messageType: currentGroup.messageType, + text: currentGroup.chunks.join(''), + startTimestamp: currentGroup.startTimestamp, + endTimestamp: currentGroup.endTimestamp, + }); + } + + // Start new group + currentGroup = { + messageType, + chunks: [text], + startTimestamp: activity.timestamp, + endTimestamp: activity.timestamp, + }; + } + } else { + // Flush current group if exists + if (currentGroup) { + result.push({ + kind: 'grouped_text', + messageType: currentGroup.messageType, + text: currentGroup.chunks.join(''), + startTimestamp: currentGroup.startTimestamp, + endTimestamp: currentGroup.endTimestamp, + }); + currentGroup = null; + } + + // Add non-text activity as-is + result.push(activity); + } + } + + // Flush final group if exists + if (currentGroup) { + result.push({ + kind: 'grouped_text', + messageType: currentGroup.messageType, + text: currentGroup.chunks.join(''), + startTimestamp: currentGroup.startTimestamp, + endTimestamp: currentGroup.endTimestamp, + }); + } + + return result; +} diff --git a/packages/server/src/server/acp/agent-manager.ts b/packages/server/src/server/acp/agent-manager.ts index 5378d35a1..078e27a4e 100644 --- a/packages/server/src/server/acp/agent-manager.ts +++ b/packages/server/src/server/acp/agent-manager.ts @@ -349,6 +349,9 @@ export class AgentManager { } agent.status = "killed"; + + // Notify subscribers BEFORE removing from manager + // This ensures subscribers can still query agent info this.notifySubscribers(agentId); // Kill the process @@ -361,8 +364,11 @@ export class AgentManager { } }, 5000); - // Remove from manager - this.agents.delete(agentId); + // Remove from manager after a small delay to allow status updates to propagate + setTimeout(() => { + this.agents.delete(agentId); + console.log(`[Agent ${agentId}] Removed from manager`); + }, 100); } /** diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3cf857726..881169fcf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -145,6 +145,41 @@ export class Session { notification: update.notification, }, }); + + // Check if this is a status change notification + // The agent manager sends custom notifications with status field + const notification = update.notification as any; + if (notification && notification.sessionUpdate && notification.sessionUpdate.status) { + const status = notification.sessionUpdate.status; + + // Get current agent info + try { + const info = this.agentManager!.listAgents().find(a => a.id === agentId); + if (info) { + // Emit agent_status message + this.emit({ + type: "agent_status", + payload: { + agentId, + status, + info: { + id: info.id, + status: info.status, + createdAt: info.createdAt.toISOString(), + type: info.type, + sessionId: info.sessionId, + error: info.error, + currentModeId: info.currentModeId, + availableModes: info.availableModes, + }, + }, + }); + console.log(`[Session ${this.clientId}] Agent ${agentId} status changed to: ${status}`); + } + } catch (error) { + console.error(`[Session ${this.clientId}] Failed to get agent info for status update:`, error); + } + } } );