mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user