feat: enhance tool usage tracking and UI components across services

- Refactored TeamDataService and TeamProvisioningService to replace tool usage counts with structured tool call details, improving accuracy and clarity in tool tracking.
- Introduced ToolCallMeta type for better representation of tool call metadata, including name and preview.
- Updated tool summary generation to utilize new tool call details, enhancing the visibility of tool interactions in messages.
- Enhanced LeadThoughtsGroupRow to aggregate tool calls for improved tooltip display, providing users with clearer insights into tool usage.
- Modified UI components to accommodate changes in tool summary handling, ensuring a consistent user experience.
This commit is contained in:
iliya 2026-03-06 16:15:19 +02:00
parent b0211e5e08
commit ff5e877023
8 changed files with 197 additions and 58 deletions

View file

@ -17,7 +17,7 @@ import {
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@ -60,6 +60,7 @@ import type {
TeamTaskStatus,
TeamTaskWithKanban,
UpdateKanbanPatch,
ToolCallMeta,
} from '@shared/types';
const logger = createLogger('Service:TeamDataService');
@ -1504,9 +1505,9 @@ export class TeamDataService {
const combined = stripAgentBlocks(textParts.join('\n')).trim();
if (combined.length < MIN_TEXT_LENGTH) continue;
// Count tool_use blocks from following lines (text and tool_use are separate in JSONL).
// Collect tool_use details from following lines (text and tool_use are separate in JSONL).
// tool_result (type=user) lines are interleaved between tool_use lines — skip them.
const toolCounts = new Map<string, number>();
const toolCallsList: ToolCallMeta[] = [];
const lookaheadLimit = Math.min(i + 200, lines.length);
for (let j = i + 1; j < lookaheadLimit; j++) {
const tLine = lines[j]?.trim();
@ -1525,11 +1526,16 @@ export class TeamDataService {
if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop
for (const b of tBlocks) {
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
toolCounts.set(b.name, (toolCounts.get(b.name) ?? 0) + 1);
const input = (b.input ?? {}) as Record<string, unknown>;
toolCallsList.push({
name: b.name as string,
preview: extractToolPreview(b.name as string, input),
});
}
}
}
const toolSummary = formatToolSummaryFromMap(toolCounts);
const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined;
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
const textPrefix = combined
@ -1550,6 +1556,7 @@ export class TeamDataService {
leadSessionId: config.leadSessionId,
messageId,
toolSummary,
toolCalls,
});
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}

View file

@ -21,7 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
@ -50,6 +50,7 @@ import type {
TeamProvisioningProgress,
TeamProvisioningState,
TeamTask,
ToolCallMeta,
} from '@shared/types';
const logger = createLogger('Service:TeamProvisioning');
@ -164,8 +165,8 @@ interface ProvisioningRun {
} | null;
/** Monotonic counter for individual lead assistant messages. */
leadMsgSeq: number;
/** Accumulated tool_use counts between text messages (tool name → count). */
pendingToolCounts: Map<string, number>;
/** Accumulated tool_use details between text messages. */
pendingToolCalls: ToolCallMeta[];
/** Throttle timestamp for emitting inbox refresh events for lead text. */
lastLeadTextEmitMs: number;
/**
@ -1747,7 +1748,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_config',
leadRelayCapture: null,
leadMsgSeq: 0,
pendingToolCounts: new Map(),
pendingToolCalls: [],
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
@ -2047,7 +2048,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_members',
leadRelayCapture: null,
leadMsgSeq: 0,
pendingToolCounts: new Map(),
pendingToolCalls: [],
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
@ -2924,12 +2925,11 @@ export class TeamProvisioningService {
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
// Attach accumulated tool counts from preceding tool_use messages, then reset.
const toolSummary =
run.pendingToolCounts.size > 0
? formatToolSummaryFromMap(run.pendingToolCounts)
: undefined;
run.pendingToolCounts.clear();
// Attach accumulated tool call details from preceding tool_use messages, then reset.
const toolCalls =
run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
run.pendingToolCalls = [];
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
@ -2939,6 +2939,7 @@ export class TeamProvisioningService {
messageId,
source: 'lead_process',
toolSummary,
toolCalls,
};
this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
@ -2959,8 +2960,8 @@ export class TeamProvisioningService {
}
}
// Accumulate tool_use counts from tool-only messages (text + tool_use are separate in stream-json).
// These counts will be attached to the next text message as toolSummary.
// Accumulate tool_use details from tool-only messages (text + tool_use are separate in stream-json).
// These details will be attached to the next text message as toolCalls/toolSummary.
if (run.provisioningComplete) {
for (const block of content ?? []) {
if (
@ -2968,10 +2969,11 @@ export class TeamProvisioningService {
typeof block.name === 'string' &&
block.name !== 'SendMessage'
) {
run.pendingToolCounts.set(
block.name as string,
(run.pendingToolCounts.get(block.name as string) ?? 0) + 1
);
const input = (block.input ?? {}) as Record<string, unknown>;
run.pendingToolCalls.push({
name: block.name as string,
preview: extractToolPreview(block.name as string, input),
});
}
}
}

View file

@ -77,6 +77,19 @@ export class TeamSentMessagesStore {
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
toolCalls: Array.isArray(row.toolCalls)
? (row.toolCalls as unknown[])
.filter(
(tc): tc is { name: string; preview?: string } =>
tc != null &&
typeof tc === 'object' &&
typeof (tc as Record<string, unknown>).name === 'string'
)
.map((tc) => ({
name: tc.name,
preview: typeof tc.preview === 'string' ? tc.preview : undefined,
}))
: undefined,
});
}

View file

@ -8,6 +8,7 @@ import { useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { nameColorSet } from '@renderer/utils/projectColor';
import { useStore } from '@renderer/store';
import {
Activity,
@ -67,17 +68,17 @@ export const SortableTab = ({
)
);
const teamColor = useStore((s) => {
const teamColorSet = useStore((s) => {
if (tab.type !== 'team' || !tab.teamName) return null;
const team = s.teamByName[tab.teamName];
if (team?.color) return team.color;
// Fallback: selectedTeamData may be available before teamByName is populated
if (s.selectedTeamName === tab.teamName && s.selectedTeamData?.config.color) {
return s.selectedTeamData.config.color;
}
return null;
const explicitColor =
team?.color ??
(s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined);
if (explicitColor) return getTeamColorSet(explicitColor);
// Fallback: deterministic color derived from display name
const displayName = team?.displayName ?? tab.label;
return nameColorSet(displayName);
});
const teamColorSet = teamColor ? getTeamColorSet(teamColor) : null;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,

View file

@ -561,9 +561,7 @@ export const TeamListView = (): React.JSX.Element => {
void fetchTeams();
}}
>
{teamsLoading ? (
<RotateCcw className="size-3.5 animate-spin" />
) : null}
{teamsLoading ? <RotateCcw className="size-3.5 animate-spin" /> : null}
Refresh
</Button>
</div>
@ -671,7 +669,7 @@ export const TeamListView = (): React.JSX.Element => {
key={team.teamName}
role="button"
tabIndex={0}
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
@ -695,7 +693,11 @@ export const TeamListView = (): React.JSX.Element => {
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<div
className={
teamColorSet ? 'relative z-10 flex flex-1 flex-col' : 'flex flex-1 flex-col'
}
>
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
@ -779,6 +781,8 @@ export const TeamListView = (): React.JSX.Element => {
Members: {team.memberCount}
</Badge>
)}
</div>
<div className="mt-auto">
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
@ -831,8 +835,8 @@ export const TeamListView = (): React.JSX.Element => {
</div>
);
})()}
{renderTeamRecentPaths(team, status)}
</div>
{renderTeamRecentPaths(team, status)}
</div>
</div>
);

View file

@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useStore } from '@renderer/store';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import type { InboxMessage } from '@shared/types';
import type { InboxMessage, ToolCallMeta } from '@shared/types';
export interface LeadThoughtGroup {
type: 'lead-thoughts';
@ -102,25 +102,50 @@ function isRecentTimestamp(timestamp: string): boolean {
return Date.now() - t <= LIVE_WINDOW_MS;
}
function ToolSummaryTooltipContent({ summary }: { summary: string }): JSX.Element {
const parsed = parseToolSummary(summary);
if (!parsed) return <span>{summary}</span>;
const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
return (
<div className="flex flex-col gap-0.5">
<div className="mb-0.5 text-[10px] text-text-secondary">
{parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
</div>
{sorted.map(([name, count]) => (
<div key={name} className="flex justify-between gap-3">
<span>{name}</span>
<span className="text-text-secondary">{count}</span>
function ToolSummaryTooltipContent({
toolCalls,
toolSummary,
}: {
toolCalls?: ToolCallMeta[];
toolSummary?: string;
}): JSX.Element {
if (toolCalls && toolCalls.length > 0) {
return (
<div className="flex max-h-[300px] flex-col gap-0.5 overflow-y-auto">
<div className="mb-0.5 text-[10px] text-text-secondary">
{toolCalls.length} {toolCalls.length === 1 ? 'tool call' : 'tool calls'}
</div>
))}
</div>
);
{toolCalls.map((tc, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="shrink-0 font-semibold">{tc.name}</span>
{tc.preview && <span className="truncate text-text-secondary">{tc.preview}</span>}
</div>
))}
</div>
);
}
if (toolSummary) {
const parsed = parseToolSummary(toolSummary);
if (parsed) {
const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
return (
<div className="flex flex-col gap-0.5">
<div className="mb-0.5 text-[10px] text-text-secondary">
{parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
</div>
{sorted.map(([name, count]) => (
<div key={name} className="flex justify-between gap-3">
<span>{name}</span>
<span className="text-text-secondary">×{count}</span>
</div>
))}
</div>
);
}
}
return <span>{toolSummary ?? ''}</span>;
}
export const LeadThoughtsGroupRow = ({
@ -170,6 +195,15 @@ export const LeadThoughtsGroupRow = ({
return formatToolSummary({ total, byName: merged });
}, [thoughts]);
// Aggregate all toolCalls across thoughts for header tooltip
const allToolCalls = useMemo(() => {
const calls: ToolCallMeta[] = [];
for (const t of thoughts) {
if (t.toolCalls) calls.push(...t.toolCalls);
}
return calls.length > 0 ? calls : undefined;
}, [thoughts]);
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
const computeIsLive = useCallback(
() =>
@ -273,7 +307,10 @@ export const LeadThoughtsGroupRow = ({
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="font-mono text-[11px]">
<ToolSummaryTooltipContent summary={totalToolSummary} />
<ToolSummaryTooltipContent
toolCalls={allToolCalls}
toolSummary={totalToolSummary}
/>
</TooltipContent>
</Tooltip>
)}
@ -337,8 +374,11 @@ export const LeadThoughtsGroupRow = ({
🔧 {thought.toolSummary}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="font-mono text-[11px]">
<ToolSummaryTooltipContent summary={thought.toolSummary} />
<TooltipContent side="top" align="start" className="font-mono text-[11px]">
<ToolSummaryTooltipContent
toolCalls={thought.toolCalls}
toolSummary={thought.toolSummary}
/>
</TooltipContent>
</Tooltip>
)}

View file

@ -186,6 +186,14 @@ export interface AttachmentFileData {
mimeType: AttachmentMediaType;
}
/** Lightweight metadata for a single tool call (for UI display in tooltips). */
export interface ToolCallMeta {
/** Tool name, e.g. "Read", "Bash", "Grep" */
name: string;
/** Human-readable preview extracted from input args, e.g. "index.ts", "grep -r foo" */
preview?: string;
}
export interface InboxMessage {
from: string;
to?: string;
@ -201,6 +209,8 @@ export interface InboxMessage {
leadSessionId?: string;
/** Tool usage summary from assistant message, e.g. "3 tools (2 Read, Bash)" */
toolSummary?: string;
/** Structured tool call details for tooltip display. */
toolCalls?: ToolCallMeta[];
}
export interface SendMessageRequest {

View file

@ -1,3 +1,5 @@
import type { ToolCallMeta } from '@shared/types';
export interface ToolSummaryData {
total: number;
byName: Record<string, number>;
@ -55,3 +57,63 @@ export function formatToolSummaryFromMap(counts: Map<string, number>): string |
.join(', ');
return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`;
}
/** Format tool summary from an array of ToolCallMeta. */
export function formatToolSummaryFromCalls(calls: ToolCallMeta[]): string | undefined {
if (calls.length === 0) return undefined;
const counts = new Map<string, number>();
for (const c of calls) counts.set(c.name, (counts.get(c.name) ?? 0) + 1);
return formatToolSummaryFromMap(counts);
}
function baseName(filePath: string): string {
return filePath.split(/[/\\]/).pop() ?? filePath;
}
function truncateStr(str: string, max: number): string {
return str.length <= max ? str : str.slice(0, max) + '...';
}
/** Extract a short human-readable preview from tool_use input arguments. */
export function extractToolPreview(
name: string,
input: Record<string, unknown>
): string | undefined {
switch (name) {
case 'Read':
case 'Edit':
case 'Write':
return typeof input.file_path === 'string' ? baseName(input.file_path) : undefined;
case 'Bash':
return typeof input.description === 'string'
? truncateStr(input.description, 60)
: typeof input.command === 'string'
? truncateStr(input.command, 60)
: undefined;
case 'Grep':
case 'Glob':
return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined;
case 'Agent':
case 'TaskCreate':
return typeof input.prompt === 'string'
? truncateStr(input.prompt, 60)
: typeof input.description === 'string'
? truncateStr(input.description, 60)
: undefined;
case 'WebFetch':
if (typeof input.url === 'string') {
try {
return new URL(input.url).hostname;
} catch {
return truncateStr(input.url, 40);
}
}
return undefined;
case 'WebSearch':
return typeof input.query === 'string' ? truncateStr(input.query, 40) : undefined;
default: {
const v = input.name ?? input.path ?? input.file ?? input.query ?? input.command;
return typeof v === 'string' ? truncateStr(v, 50) : undefined;
}
}
}