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:
co-authored by
Claude
Happy
parent
674977d6f8
commit
015d0f0e27
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { View, Text, Pressable } from 'react-native';
|
import { View, Text, Pressable } from 'react-native';
|
||||||
import { StyleSheet } from 'react-native-unistyles';
|
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 {
|
interface AgentActivityItemProps {
|
||||||
item: GroupedTextMessage | AgentActivity;
|
item: GroupedTextMessage | MergedToolCall | AgentActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
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);
|
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 (
|
return (
|
||||||
<View style={stylesheet.toolCard}>
|
<View style={stylesheet.toolCard}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -94,21 +85,19 @@ function ToolCallItem({ update, timestamp }: { update: SessionUpdate; timestamp:
|
|||||||
>
|
>
|
||||||
<View style={stylesheet.toolHeaderLeft}>
|
<View style={stylesheet.toolHeaderLeft}>
|
||||||
<Text style={stylesheet.timestamp}>
|
<Text style={stylesheet.timestamp}>
|
||||||
{formatTimestamp(timestamp)}
|
{formatTimestamp(item.startTimestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={stylesheet.toolTitleRow}>
|
<View style={stylesheet.toolTitleRow}>
|
||||||
<Text style={stylesheet.toolIcon}>{getToolIcon(toolKind)}</Text>
|
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
|
||||||
<Text style={stylesheet.toolTitle}>{title}</Text>
|
<Text style={stylesheet.toolTitle}>{item.title}</Text>
|
||||||
{status && (
|
<View
|
||||||
<View
|
style={[
|
||||||
style={[
|
stylesheet.statusBadge,
|
||||||
stylesheet.statusBadge,
|
{ backgroundColor: getStatusColor(item.status) },
|
||||||
{ backgroundColor: getStatusColor(status) },
|
]}
|
||||||
]}
|
>
|
||||||
>
|
<Text style={stylesheet.statusText}>{item.status}</Text>
|
||||||
<Text style={stylesheet.statusText}>{status}</Text>
|
</View>
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
|
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
|
||||||
@@ -116,25 +105,25 @@ function ToolCallItem({ update, timestamp }: { update: SessionUpdate; timestamp:
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<View style={stylesheet.toolContent}>
|
<View style={stylesheet.toolContent}>
|
||||||
{update.rawInput && (
|
{item.rawInput && (
|
||||||
<View style={stylesheet.section}>
|
<View style={stylesheet.section}>
|
||||||
<Text style={stylesheet.sectionTitle}>Input:</Text>
|
<Text style={stylesheet.sectionTitle}>Input:</Text>
|
||||||
<Text style={stylesheet.code}>
|
<Text style={stylesheet.code}>
|
||||||
{JSON.stringify(update.rawInput, null, 2)}
|
{JSON.stringify(item.rawInput, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{update.rawOutput && (
|
{item.rawOutput && (
|
||||||
<View style={stylesheet.section}>
|
<View style={stylesheet.section}>
|
||||||
<Text style={stylesheet.sectionTitle}>Output:</Text>
|
<Text style={stylesheet.sectionTitle}>Output:</Text>
|
||||||
<Text style={stylesheet.code}>
|
<Text style={stylesheet.code}>
|
||||||
{JSON.stringify(update.rawOutput, null, 2)}
|
{JSON.stringify(item.rawOutput, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{!update.rawInput && !update.rawOutput && (
|
{!item.rawInput && !item.rawOutput && (
|
||||||
<Text style={stylesheet.emptyText}>
|
<Text style={stylesheet.emptyText}>
|
||||||
{isUpdate ? 'Tool call updated' : 'No details available'}
|
No details available
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -218,15 +207,15 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
|||||||
return <GroupedTextItem item={item} />;
|
return <GroupedTextItem item={item} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merged tool call
|
||||||
|
if ('kind' in item && item.kind === 'merged_tool_call') {
|
||||||
|
return <MergedToolCallItem item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Individual activity
|
// Individual activity
|
||||||
const activity = item as AgentActivity;
|
const activity = item as AgentActivity;
|
||||||
const update = activity.update;
|
const update = activity.update;
|
||||||
|
|
||||||
// Tool calls
|
|
||||||
if (update.kind === 'tool_call' || update.kind === 'tool_call_update') {
|
|
||||||
return <ToolCallItem update={update} timestamp={activity.timestamp} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan
|
// Plan
|
||||||
if (update.kind === 'plan') {
|
if (update.kind === 'plan') {
|
||||||
return <PlanItem update={update} timestamp={activity.timestamp} />;
|
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 { AgentStatus } from '@server/server/acp/types';
|
||||||
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||||
import { AgentActivityItem } from './agent-activity';
|
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 {
|
export interface AgentStreamViewProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -94,8 +94,8 @@ export function AgentStreamView({
|
|||||||
})
|
})
|
||||||
.filter((activity): activity is AgentActivity => activity !== null);
|
.filter((activity): activity is AgentActivity => activity !== null);
|
||||||
|
|
||||||
// Group consecutive text chunks
|
// Group consecutive text chunks and merge tool calls
|
||||||
return groupTextChunks(activities);
|
return groupActivities(activities);
|
||||||
}, [updates]);
|
}, [updates]);
|
||||||
|
|
||||||
// Auto-scroll to bottom when new updates arrive
|
// Auto-scroll to bottom when new updates arrive
|
||||||
|
|||||||
@@ -102,6 +102,23 @@ export interface GroupedTextMessage {
|
|||||||
endTimestamp: Date;
|
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
|
* 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> {
|
export function groupActivities(activities: AgentActivity[]): Array<GroupedTextMessage | MergedToolCall | AgentActivity> {
|
||||||
const result: Array<GroupedTextMessage | AgentActivity> = [];
|
const result: Array<GroupedTextMessage | MergedToolCall | AgentActivity> = [];
|
||||||
let currentGroup: {
|
|
||||||
|
// Track current text group
|
||||||
|
let currentTextGroup: {
|
||||||
messageType: 'user' | 'agent' | 'thought';
|
messageType: 'user' | 'agent' | 'thought';
|
||||||
chunks: string[];
|
chunks: string[];
|
||||||
startTimestamp: Date;
|
startTimestamp: Date;
|
||||||
endTimestamp: Date;
|
endTimestamp: Date;
|
||||||
} | null = null;
|
} | 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) {
|
for (const activity of activities) {
|
||||||
const update = activity.update;
|
const update = activity.update;
|
||||||
|
|
||||||
// Check if this is a text chunk
|
// Handle text chunks
|
||||||
if (
|
if (
|
||||||
update.kind === 'user_message_chunk' ||
|
update.kind === 'user_message_chunk' ||
|
||||||
update.kind === 'agent_message_chunk' ||
|
update.kind === 'agent_message_chunk' ||
|
||||||
@@ -211,56 +258,117 @@ export function groupTextChunks(activities: AgentActivity[]): Array<GroupedTextM
|
|||||||
const text = update.content.text;
|
const text = update.content.text;
|
||||||
|
|
||||||
// If we have a current group of the same type, add to it
|
// If we have a current group of the same type, add to it
|
||||||
if (currentGroup && currentGroup.messageType === messageType) {
|
if (currentTextGroup && currentTextGroup.messageType === messageType) {
|
||||||
currentGroup.chunks.push(text);
|
currentTextGroup.chunks.push(text);
|
||||||
currentGroup.endTimestamp = activity.timestamp;
|
currentTextGroup.endTimestamp = activity.timestamp;
|
||||||
} else {
|
} else {
|
||||||
// Flush current group if exists
|
flushTextGroup();
|
||||||
if (currentGroup) {
|
|
||||||
result.push({
|
|
||||||
kind: 'grouped_text',
|
|
||||||
messageType: currentGroup.messageType,
|
|
||||||
text: currentGroup.chunks.join(''),
|
|
||||||
startTimestamp: currentGroup.startTimestamp,
|
|
||||||
endTimestamp: currentGroup.endTimestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new group
|
// Start new group
|
||||||
currentGroup = {
|
currentTextGroup = {
|
||||||
messageType,
|
messageType,
|
||||||
chunks: [text],
|
chunks: [text],
|
||||||
startTimestamp: activity.timestamp,
|
startTimestamp: activity.timestamp,
|
||||||
endTimestamp: activity.timestamp,
|
endTimestamp: activity.timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Flush current group if exists
|
// Handle tool calls and updates
|
||||||
if (currentGroup) {
|
else if (update.kind === 'tool_call' || update.kind === 'tool_call_update') {
|
||||||
result.push({
|
flushTextGroup();
|
||||||
kind: 'grouped_text',
|
|
||||||
messageType: currentGroup.messageType,
|
|
||||||
text: currentGroup.chunks.join(''),
|
|
||||||
startTimestamp: currentGroup.startTimestamp,
|
|
||||||
endTimestamp: currentGroup.endTimestamp,
|
|
||||||
});
|
|
||||||
currentGroup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
result.push(activity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush final group if exists
|
// Flush final text group
|
||||||
if (currentGroup) {
|
flushTextGroup();
|
||||||
result.push({
|
|
||||||
kind: 'grouped_text',
|
// Replace placeholders with merged tool calls
|
||||||
messageType: currentGroup.messageType,
|
for (const toolCall of toolCallsById.values()) {
|
||||||
text: currentGroup.chunks.join(''),
|
result[toolCall.insertIndex] = {
|
||||||
startTimestamp: currentGroup.startTimestamp,
|
kind: 'merged_tool_call',
|
||||||
endTimestamp: currentGroup.endTimestamp,
|
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;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user