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:
Mohamed Boudra
2025-10-22 18:10:29 +02:00
parent e1e14b1473
commit 674977d6f8
5 changed files with 776 additions and 181 deletions

View 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,
},
}));

View File

@@ -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],
},
}));

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

View File

@@ -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);
}
/**

View File

@@ -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);
}
}
}
);