feat: merge tool calls with their updates into single unified view

Instead of showing tool_call and tool_call_update as separate events,
now merge them by toolCallId into a single MergedToolCall that updates
as new information comes in.

**Changes:**
- Added MergedToolCall type with start/end timestamps
- Renamed groupTextChunks to groupActivities with tool call merging logic
- Track tool calls by ID and merge all updates into single entry
- Replace ToolCallItem with MergedToolCallItem component
- AgentStreamView now uses groupActivities for unified grouping

**User Experience:**
- Each tool call appears once and updates in place
- Status changes from pending → in_progress → completed
- Input/output merge as they become available
- Cleaner, less noisy activity view

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:17:53 +02:00
parent 674977d6f8
commit 015d0f0e27
3 changed files with 177 additions and 80 deletions

View File

@@ -1,10 +1,10 @@
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';
import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity';
interface AgentActivityItemProps {
item: GroupedTextMessage | AgentActivity;
item: GroupedTextMessage | MergedToolCall | AgentActivity;
}
function formatTimestamp(date: Date): string {
@@ -74,18 +74,9 @@ function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
);
}
function ToolCallItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
function MergedToolCallItem({ item }: { item: MergedToolCall }) {
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
@@ -94,21 +85,19 @@ function ToolCallItem({ update, timestamp }: { update: SessionUpdate; timestamp:
>
<View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(timestamp)}
{formatTimestamp(item.startTimestamp)}
</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>
)}
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text>
<View
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(item.status) },
]}
>
<Text style={stylesheet.statusText}>{item.status}</Text>
</View>
</View>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
@@ -116,25 +105,25 @@ function ToolCallItem({ update, timestamp }: { update: SessionUpdate; timestamp:
{isExpanded && (
<View style={stylesheet.toolContent}>
{update.rawInput && (
{item.rawInput && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(update.rawInput, null, 2)}
{JSON.stringify(item.rawInput, null, 2)}
</Text>
</View>
)}
{update.rawOutput && (
{item.rawOutput && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(update.rawOutput, null, 2)}
{JSON.stringify(item.rawOutput, null, 2)}
</Text>
</View>
)}
{!update.rawInput && !update.rawOutput && (
{!item.rawInput && !item.rawOutput && (
<Text style={stylesheet.emptyText}>
{isUpdate ? 'Tool call updated' : 'No details available'}
No details available
</Text>
)}
</View>
@@ -218,15 +207,15 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
return <GroupedTextItem item={item} />;
}
// Merged tool call
if ('kind' in item && item.kind === 'merged_tool_call') {
return <MergedToolCallItem 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} />;

View File

@@ -5,7 +5,7 @@ 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';
import { parseSessionUpdate, groupActivities, type AgentActivity } from '@/types/agent-activity';
export interface AgentStreamViewProps {
agentId: string;
@@ -94,8 +94,8 @@ export function AgentStreamView({
})
.filter((activity): activity is AgentActivity => activity !== null);
// Group consecutive text chunks
return groupTextChunks(activities);
// Group consecutive text chunks and merge tool calls
return groupActivities(activities);
}, [updates]);
// Auto-scroll to bottom when new updates arrive

View File

@@ -102,6 +102,23 @@ export interface GroupedTextMessage {
endTimestamp: Date;
}
/**
* Merged tool call (initial call + all updates combined)
*/
export interface MergedToolCall {
kind: 'merged_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[];
startTimestamp: Date;
endTimestamp: Date;
}
/**
* Parse SessionNotification into typed SessionUpdate
*/
@@ -183,21 +200,51 @@ export function parseSessionUpdate(notification: SessionNotification): SessionUp
}
/**
* Group consecutive text chunks into messages
* Group consecutive text chunks into messages and merge tool calls by ID
*/
export function groupTextChunks(activities: AgentActivity[]): Array<GroupedTextMessage | AgentActivity> {
const result: Array<GroupedTextMessage | AgentActivity> = [];
let currentGroup: {
export function groupActivities(activities: AgentActivity[]): Array<GroupedTextMessage | MergedToolCall | AgentActivity> {
const result: Array<GroupedTextMessage | MergedToolCall | AgentActivity> = [];
// Track current text group
let currentTextGroup: {
messageType: 'user' | 'agent' | 'thought';
chunks: string[];
startTimestamp: Date;
endTimestamp: Date;
} | null = null;
// Track tool calls by ID
const toolCallsById = new Map<string, {
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[];
startTimestamp: Date;
endTimestamp: Date;
insertIndex: number; // Track where to insert in result
}>();
function flushTextGroup() {
if (currentTextGroup) {
result.push({
kind: 'grouped_text',
messageType: currentTextGroup.messageType,
text: currentTextGroup.chunks.join(''),
startTimestamp: currentTextGroup.startTimestamp,
endTimestamp: currentTextGroup.endTimestamp,
});
currentTextGroup = null;
}
}
for (const activity of activities) {
const update = activity.update;
// Check if this is a text chunk
// Handle text chunks
if (
update.kind === 'user_message_chunk' ||
update.kind === 'agent_message_chunk' ||
@@ -211,56 +258,117 @@ export function groupTextChunks(activities: AgentActivity[]): Array<GroupedTextM
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;
if (currentTextGroup && currentTextGroup.messageType === messageType) {
currentTextGroup.chunks.push(text);
currentTextGroup.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,
});
}
flushTextGroup();
// Start new group
currentGroup = {
currentTextGroup = {
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;
}
}
// Handle tool calls and updates
else if (update.kind === 'tool_call' || update.kind === 'tool_call_update') {
flushTextGroup();
// Add non-text activity as-is
const toolCallId = update.toolCallId;
const existing = toolCallsById.get(toolCallId);
if (update.kind === 'tool_call') {
// Initial tool call
if (!existing) {
// Create new entry and placeholder in result
const insertIndex = result.length;
result.push(null as any); // Placeholder
toolCallsById.set(toolCallId, {
toolCallId,
title: update.title,
status: update.status || 'pending',
toolKind: update.toolKind,
rawInput: update.rawInput,
rawOutput: update.rawOutput,
content: update.content,
locations: update.locations,
startTimestamp: activity.timestamp,
endTimestamp: activity.timestamp,
insertIndex,
});
} else {
// Update existing
existing.title = update.title;
if (update.status) existing.status = update.status;
if (update.toolKind) existing.toolKind = update.toolKind;
if (update.rawInput) existing.rawInput = update.rawInput;
if (update.rawOutput) existing.rawOutput = update.rawOutput;
if (update.content) existing.content = update.content;
if (update.locations) existing.locations = update.locations;
existing.endTimestamp = activity.timestamp;
}
} else {
// Tool call update
if (existing) {
// Merge update into existing
if (update.title) existing.title = update.title;
if (update.status) existing.status = update.status;
if (update.toolKind) existing.toolKind = update.toolKind;
if (update.rawInput) existing.rawInput = { ...existing.rawInput, ...update.rawInput };
if (update.rawOutput) existing.rawOutput = { ...existing.rawOutput, ...update.rawOutput };
if (update.content) existing.content = update.content;
if (update.locations) existing.locations = update.locations;
existing.endTimestamp = activity.timestamp;
} else {
// Update without initial call - create entry
const insertIndex = result.length;
result.push(null as any); // Placeholder
toolCallsById.set(toolCallId, {
toolCallId,
title: update.title || 'Tool Call',
status: update.status || 'pending',
toolKind: update.toolKind || undefined,
rawInput: update.rawInput,
rawOutput: update.rawOutput,
content: update.content || undefined,
locations: update.locations || undefined,
startTimestamp: activity.timestamp,
endTimestamp: activity.timestamp,
insertIndex,
});
}
}
}
// Handle other activities
else {
flushTextGroup();
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,
});
// Flush final text group
flushTextGroup();
// Replace placeholders with merged tool calls
for (const toolCall of toolCallsById.values()) {
result[toolCall.insertIndex] = {
kind: 'merged_tool_call',
toolCallId: toolCall.toolCallId,
title: toolCall.title,
status: toolCall.status,
toolKind: toolCall.toolKind,
rawInput: toolCall.rawInput,
rawOutput: toolCall.rawOutput,
content: toolCall.content,
locations: toolCall.locations,
startTimestamp: toolCall.startTimestamp,
endTimestamp: toolCall.endTimestamp,
};
}
return result;