diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ecec4db3..7fa3fda3 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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(); + 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; + 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; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f9eadff4..81038ae3 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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; + /** 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; + run.pendingToolCalls.push({ + name: block.name as string, + preview: extractToolPreview(block.name as string, input), + }); } } } diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index 04a61528..6e7e5bd4 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -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).name === 'string' + ) + .map((tc) => ({ + name: tc.name, + preview: typeof tc.preview === 'string' ? tc.preview : undefined, + })) + : undefined, }); } diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 7fafcfd5..b6cb0345 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -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, diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 7524b49d..9633d7af 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -561,9 +561,7 @@ export const TeamListView = (): React.JSX.Element => { void fetchTeams(); }} > - {teamsLoading ? ( - - ) : null} + {teamsLoading ? : null} Refresh @@ -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} -
+

@@ -779,6 +781,8 @@ export const TeamListView = (): React.JSX.Element => { Members: {team.memberCount} )} +

+
{(() => { const tc = taskCountsByTeam.get(team.teamName); const pending = tc?.pending ?? 0; @@ -831,8 +835,8 @@ export const TeamListView = (): React.JSX.Element => {
); })()} + {renderTeamRecentPaths(team, status)}
- {renderTeamRecentPaths(team, status)}
); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 0578c3df..fe0d2540 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -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 {summary}; - - const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]); - - return ( -
-
- {parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'} -
- {sorted.map(([name, count]) => ( -
- {name} - {count} +function ToolSummaryTooltipContent({ + toolCalls, + toolSummary, +}: { + toolCalls?: ToolCallMeta[]; + toolSummary?: string; +}): JSX.Element { + if (toolCalls && toolCalls.length > 0) { + return ( +
+
+ {toolCalls.length} {toolCalls.length === 1 ? 'tool call' : 'tool calls'}
- ))} -
- ); + {toolCalls.map((tc, i) => ( +
+ {tc.name} + {tc.preview && {tc.preview}} +
+ ))} +
+ ); + } + + if (toolSummary) { + const parsed = parseToolSummary(toolSummary); + if (parsed) { + const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]); + return ( +
+
+ {parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'} +
+ {sorted.map(([name, count]) => ( +
+ {name} + ×{count} +
+ ))} +
+ ); + } + } + + return {toolSummary ?? ''}; } 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 = ({ - + )} @@ -337,8 +374,11 @@ export const LeadThoughtsGroupRow = ({ 🔧 {thought.toolSummary}
- - + + )} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index b60a159f..660eafb4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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 { diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index ef50bfbc..df3c72be 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -1,3 +1,5 @@ +import type { ToolCallMeta } from '@shared/types'; + export interface ToolSummaryData { total: number; byName: Record; @@ -55,3 +57,63 @@ export function formatToolSummaryFromMap(counts: Map): 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(); + 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 | 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; + } + } +}