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
co-authored by Claude Happy
parent 674977d6f8
commit 015d0f0e27
3 changed files with 177 additions and 80 deletions
+25 -36
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} />;
@@ -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