mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: improve agent activity UI and fix status update propagation
**Agent Activity UI Improvements:** - Create typed discriminated unions for SessionNotification updates - Add AgentActivity component with specialized rendering for each update type - Group consecutive text chunks into single messages (like main chat) - Add collapsible tool call rendering with input/output details - Add plan rendering with task status indicators - Add fallback rendering for unknown activity types (badge + drawer) **Agent Status Update Fix:** - Fix agent killing not updating UI status - Session now detects status change notifications and emits agent_status messages - Agent manager delays agent deletion to allow status updates to propagate - Frontend agent_status handler now receives proper updates **Technical Details:** - Created /packages/app/src/types/agent-activity.ts with typed session updates - Created /packages/app/src/components/agent-activity.tsx with activity renderers - Updated AgentStreamView to use grouped activities with useMemo - Modified session.ts subscribeToAgent to emit agent_status on status changes - Modified agent-manager.ts killAgent to delay deletion for status propagation Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
439
packages/app/src/components/agent-activity.tsx
Normal file
439
packages/app/src/components/agent-activity.tsx
Normal file
@@ -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 (
|
||||
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
|
||||
<Text style={stylesheet.timestamp}>
|
||||
{formatTimestamp(item.startTimestamp)}
|
||||
</Text>
|
||||
{isThought && (
|
||||
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>
|
||||
)}
|
||||
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={stylesheet.toolCard}>
|
||||
<Pressable
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
style={stylesheet.toolHeader}
|
||||
>
|
||||
<View style={stylesheet.toolHeaderLeft}>
|
||||
<Text style={stylesheet.timestamp}>
|
||||
{formatTimestamp(timestamp)}
|
||||
</Text>
|
||||
<View style={stylesheet.toolTitleRow}>
|
||||
<Text style={stylesheet.toolIcon}>{getToolIcon(toolKind)}</Text>
|
||||
<Text style={stylesheet.toolTitle}>{title}</Text>
|
||||
{status && (
|
||||
<View
|
||||
style={[
|
||||
stylesheet.statusBadge,
|
||||
{ backgroundColor: getStatusColor(status) },
|
||||
]}
|
||||
>
|
||||
<Text style={stylesheet.statusText}>{status}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
|
||||
</Pressable>
|
||||
|
||||
{isExpanded && (
|
||||
<View style={stylesheet.toolContent}>
|
||||
{update.rawInput && (
|
||||
<View style={stylesheet.section}>
|
||||
<Text style={stylesheet.sectionTitle}>Input:</Text>
|
||||
<Text style={stylesheet.code}>
|
||||
{JSON.stringify(update.rawInput, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{update.rawOutput && (
|
||||
<View style={stylesheet.section}>
|
||||
<Text style={stylesheet.sectionTitle}>Output:</Text>
|
||||
<Text style={stylesheet.code}>
|
||||
{JSON.stringify(update.rawOutput, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{!update.rawInput && !update.rawOutput && (
|
||||
<Text style={stylesheet.emptyText}>
|
||||
{isUpdate ? 'Tool call updated' : 'No details available'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
if (update.kind !== 'plan') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={stylesheet.planCard}>
|
||||
<Pressable
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
style={stylesheet.planHeader}
|
||||
>
|
||||
<View style={stylesheet.planHeaderLeft}>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
|
||||
<Text style={stylesheet.planTitle}>
|
||||
📋 Plan ({update.entries.length} tasks)
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
|
||||
</Pressable>
|
||||
|
||||
{isExpanded && (
|
||||
<View style={stylesheet.planContent}>
|
||||
{update.entries.map((entry, idx) => (
|
||||
<View key={idx} style={stylesheet.planEntry}>
|
||||
<Text
|
||||
style={[
|
||||
stylesheet.planEntryStatus,
|
||||
{ color: getStatusColor(entry.status) },
|
||||
]}
|
||||
>
|
||||
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
|
||||
</Text>
|
||||
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={stylesheet.unknownCard}>
|
||||
<Pressable
|
||||
onPress={() => setShowDrawer(!showDrawer)}
|
||||
style={stylesheet.unknownHeader}
|
||||
>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
|
||||
<View style={stylesheet.unknownBadge}>
|
||||
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{showDrawer && (
|
||||
<View style={stylesheet.drawerContent}>
|
||||
<Text style={stylesheet.code}>
|
||||
{JSON.stringify(update, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
||||
// Grouped text message
|
||||
if ('kind' in item && item.kind === 'grouped_text') {
|
||||
return <GroupedTextItem item={item} />;
|
||||
}
|
||||
|
||||
// Individual activity
|
||||
const activity = item as AgentActivity;
|
||||
const update = activity.update;
|
||||
|
||||
// Tool calls
|
||||
if (update.kind === 'tool_call' || update.kind === 'tool_call_update') {
|
||||
return <ToolCallItem update={update} timestamp={activity.timestamp} />;
|
||||
}
|
||||
|
||||
// Plan
|
||||
if (update.kind === 'plan') {
|
||||
return <PlanItem update={update} timestamp={activity.timestamp} />;
|
||||
}
|
||||
|
||||
// Available commands update
|
||||
if (update.kind === 'available_commands_update') {
|
||||
return (
|
||||
<View style={stylesheet.card}>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
|
||||
<Text style={stylesheet.infoText}>
|
||||
Commands updated ({update.availableCommands.length} available)
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Current mode update
|
||||
if (update.kind === 'current_mode_update') {
|
||||
return (
|
||||
<View style={stylesheet.card}>
|
||||
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
|
||||
<Text style={stylesheet.infoText}>
|
||||
Mode changed to: {update.currentModeId}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown activity type
|
||||
return <UnknownActivityItem update={update} timestamp={activity.timestamp} />;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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 (
|
||||
<View style={stylesheet.updateCard}>
|
||||
<Text style={stylesheet.timestampText}>
|
||||
{formatTimestamp(update.timestamp)}
|
||||
</Text>
|
||||
<Text style={stylesheet.messageText}>{sessionUpdate.chunk || ''}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if (sessionUpdate.kind === 'tool_call') {
|
||||
return (
|
||||
<View style={stylesheet.collapsibleCard}>
|
||||
<Pressable
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
style={stylesheet.collapsibleHeader}
|
||||
>
|
||||
<Text style={stylesheet.timestampText}>
|
||||
{formatTimestamp(update.timestamp)}
|
||||
</Text>
|
||||
<Text style={stylesheet.toolCallText}>
|
||||
Tool Call: {sessionUpdate.toolName || 'unknown'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{isExpanded && sessionUpdate.arguments && (
|
||||
<View style={stylesheet.collapsibleContent}>
|
||||
<Text style={stylesheet.codeText}>
|
||||
{JSON.stringify(sessionUpdate.arguments, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle tool call updates
|
||||
if (sessionUpdate.kind === 'tool_call_update') {
|
||||
return (
|
||||
<View style={stylesheet.updateCard}>
|
||||
<Text style={stylesheet.timestampText}>
|
||||
{formatTimestamp(update.timestamp)}
|
||||
</Text>
|
||||
<Text style={stylesheet.updateStatusText}>
|
||||
{sessionUpdate.status || 'updating'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle available commands
|
||||
if (sessionUpdate.kind === 'available_commands_update') {
|
||||
const commands = sessionUpdate.commands || [];
|
||||
return (
|
||||
<View style={stylesheet.collapsibleCard}>
|
||||
<Pressable
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
style={stylesheet.collapsibleHeader}
|
||||
>
|
||||
<Text style={stylesheet.timestampText}>
|
||||
{formatTimestamp(update.timestamp)}
|
||||
</Text>
|
||||
<Text style={stylesheet.commandsText}>
|
||||
Available Commands ({commands.length})
|
||||
</Text>
|
||||
</Pressable>
|
||||
{isExpanded && (
|
||||
<View style={stylesheet.collapsibleContent}>
|
||||
{commands.map((cmd: any, idx: number) => (
|
||||
<Text key={idx} style={stylesheet.commandItem}>
|
||||
• {cmd.name || cmd}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic session update
|
||||
return (
|
||||
<View style={stylesheet.updateCard}>
|
||||
<Text style={stylesheet.timestampText}>
|
||||
{formatTimestamp(update.timestamp)}
|
||||
</Text>
|
||||
<Text style={stylesheet.codeText}>
|
||||
{JSON.stringify(sessionUpdate, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown notification types
|
||||
return (
|
||||
<View style={stylesheet.updateCard}>
|
||||
<Text style={stylesheet.timestampText}>
|
||||
{formatTimestamp(update.timestamp)}
|
||||
</Text>
|
||||
<Text style={stylesheet.codeText}>
|
||||
{JSON.stringify(notification, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentStreamView({
|
||||
agentId,
|
||||
agent,
|
||||
@@ -196,10 +79,29 @@ export function AgentStreamView({
|
||||
const scrollViewRef = useRef<ScrollView>(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 ? (
|
||||
<View style={stylesheet.emptyState}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
No updates yet.{'\n'}Waiting for agent activity...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
updates.map((update, idx) => <AgentUpdate key={idx} update={update} />)
|
||||
groupedActivities.map((item, idx) => (
|
||||
<AgentActivityItem key={idx} item={item} />
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
|
||||
267
packages/app/src/types/agent-activity.ts
Normal file
267
packages/app/src/types/agent-activity.ts
Normal file
@@ -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<string, unknown>;
|
||||
rawOutput?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
rawOutput?: Record<string, unknown>;
|
||||
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<GroupedTextMessage | AgentActivity> {
|
||||
const result: Array<GroupedTextMessage | AgentActivity> = [];
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user