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:
parent
b0211e5e08
commit
ff5e877023
8 changed files with 197 additions and 58 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue