diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index cbae35bb..155cc2b6 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -82,6 +82,10 @@ export function drawAgents( ctx.stroke(); } + if (node.activeTool) { + drawToolCard(ctx, x, y, r, node.activeTool, time); + } + // Name + role label (single line: "jack · developer") const labelText = node.role ? `${node.label} · ${node.role}` : node.label; drawLabel(ctx, x, y, r, labelText, color); @@ -147,7 +151,7 @@ function drawHexBody( ctx.fill(); // Scanline effect - const scanSpeed = state === 'active' || state === 'thinking' + const scanSpeed = state === 'active' || state === 'thinking' || state === 'tool_calling' ? ANIM.scanline.active : ANIM.scanline.normal; const scanY = ((time * scanSpeed) % (r * 2)) - r; @@ -174,6 +178,87 @@ function drawHexBody( ctx.stroke(); } +function truncateCardText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, +): string { + if (ctx.measureText(text).width <= maxWidth) return text; + let out = text; + while (out.length > 1 && ctx.measureText(`${out}...`).width > maxWidth) { + out = out.slice(0, -1); + } + return `${out}...`; +} + +function drawToolCard( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + tool: NonNullable, + time: number, +): void { + const labelBase = tool.preview ? `${tool.name}: ${tool.preview}` : tool.name; + const labelText = + tool.state === 'error' + ? `${tool.name}: failed` + : tool.state === 'complete' && tool.resultPreview + ? `${tool.name}: ${tool.resultPreview}` + : labelBase; + + ctx.save(); + ctx.font = '8px monospace'; + const truncated = truncateCardText(ctx, labelText, 104); + const textWidth = ctx.measureText(truncated).width; + const cardW = Math.max(62, Math.min(124, textWidth + 24)); + const cardH = 18; + const cardX = x - cardW / 2; + const cardY = y - r - cardH - 10; + const accent = + tool.state === 'error' + ? COLORS.error + : tool.state === 'complete' + ? COLORS.complete + : COLORS.tool_calling; + + ctx.beginPath(); + ctx.roundRect(cardX, cardY, cardW, cardH, 4); + ctx.fillStyle = tool.state === 'running' ? 'rgba(10, 15, 30, 0.85)' : 'rgba(10, 15, 30, 0.78)'; + ctx.fill(); + ctx.strokeStyle = hexWithAlpha(accent, 0.7); + ctx.lineWidth = 1; + ctx.stroke(); + + const indicatorX = cardX + 10; + const indicatorY = cardY + cardH / 2; + + if (tool.state === 'running') { + ctx.beginPath(); + ctx.arc( + indicatorX, + indicatorY, + 4.5, + time * 3, + time * 3 + Math.PI * 1.2, + ); + ctx.strokeStyle = accent; + ctx.lineWidth = 1.4; + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.arc(indicatorX, indicatorY, 2.5, 0, Math.PI * 2); + ctx.fillStyle = accent; + ctx.fill(); + } + + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = accent; + ctx.fillText(truncated, indicatorX + 8, indicatorY); + ctx.restore(); +} + function drawBreathing( ctx: CanvasRenderingContext2D, x: number, diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 9cbe403a..8ed6af1a 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -55,6 +55,26 @@ export interface GraphNode { currentTaskSubject?: string; /** Agent is awaiting tool approval from the user */ pendingApproval?: boolean; + /** Currently running or just-finished tool activity shown near the node */ + activeTool?: { + name: string; + preview?: string; + state: 'running' | 'complete' | 'error'; + startedAt: string; + finishedAt?: string; + resultPreview?: string; + source: 'runtime' | 'inbox'; + }; + /** Recent completed tool activity for popovers and secondary UI */ + recentTools?: Array<{ + name: string; + preview?: string; + state: 'complete' | 'error'; + startedAt: string; + finishedAt: string; + resultPreview?: string; + source: 'runtime' | 'inbox'; + }>; // ─── Task-specific ───────────────────────────────────────────────────── /** Short display ID (e.g., "#3") */ @@ -119,7 +139,7 @@ export interface GraphParticle { // ─── Domain Reference (opaque back-pointer) ────────────────────────────────── export type GraphDomainRef = - | { kind: 'lead'; teamName: string } + | { kind: 'lead'; teamName: string; memberName: string } | { kind: 'member'; teamName: string; memberName: string } | { kind: 'task'; teamName: string; taskId: string } | { kind: 'process'; teamName: string; processId: string }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3d510f15..e707c71f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -45,7 +45,11 @@ import { type ParsedTeammateContent, } from '@shared/utils/teammateMessageParser'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; -import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; +import { + extractToolPreview, + extractToolResultPreview, + formatToolSummaryFromCalls, +} from '@shared/utils/toolSummary'; import * as agentTeamsControllerModule from 'agent-teams-controller'; import { type ChildProcess, type spawn } from 'child_process'; import { randomUUID } from 'crypto'; @@ -80,6 +84,7 @@ function killTeamProcess(child: ChildProcess | null | undefined): void { } import type { + ActiveToolCall, CrossTeamSendResult, InboxMessage, LeadContextUsage, @@ -95,6 +100,7 @@ import type { TeamProvisioningState, TeamRuntimeState, TeamTask, + ToolActivityEventPayload, ToolApprovalAutoResolved, ToolApprovalEvent, ToolApprovalRequest, @@ -275,6 +281,8 @@ interface ProvisioningRun { leadMsgSeq: number; /** Accumulated tool_use details between text messages. */ pendingToolCalls: ToolCallMeta[]; + /** Active runtime tool calls keyed by tool_use_id. */ + activeToolCalls: Map; /** True when a direct MCP cross_team_send happened and sentMessages history should refresh. */ pendingDirectCrossTeamSendRefresh: boolean; /** Throttle timestamp for emitting inbox refresh events for lead text. */ @@ -1673,6 +1681,18 @@ export class TeamProvisioningService { return text.length > 0 ? text : null; } + private extractStreamContentBlocks(msg: Record): Record[] { + const topLevelContent = msg.content; + if (Array.isArray(topLevelContent)) { + return topLevelContent as Record[]; + } + + const message = msg.message; + if (!message || typeof message !== 'object') return []; + const innerContent = (message as Record).content; + return Array.isArray(innerContent) ? (innerContent as Record[]) : []; + } + private async matchCrossTeamLeadInboxMessages( teamName: string, leadName: string, @@ -2089,6 +2109,94 @@ export class TeamProvisioningService { }); } + private emitToolActivity(run: ProvisioningRun, payload: ToolActivityEventPayload): void { + if (!this.isCurrentTrackedRun(run)) return; + this.teamChangeEmitter?.({ + type: 'tool-activity', + teamName: run.teamName, + runId: run.runId, + detail: JSON.stringify(payload), + }); + } + + private startRuntimeToolActivity( + run: ProvisioningRun, + memberName: string, + block: Record + ): void { + const rawId = typeof block.id === 'string' ? block.id.trim() : ''; + if (!rawId) return; + + const toolUseId = rawId; + if (run.activeToolCalls.has(toolUseId)) return; + + const toolName = typeof block.name === 'string' ? block.name : 'unknown'; + const input = (block.input ?? {}) as Record; + const activity: ActiveToolCall = { + memberName, + toolUseId, + toolName, + preview: extractToolPreview(toolName, input), + startedAt: nowIso(), + state: 'running', + source: 'runtime', + }; + + run.activeToolCalls.set(toolUseId, activity); + this.emitToolActivity(run, { + action: 'start', + activity: { + memberName: activity.memberName, + toolUseId: activity.toolUseId, + toolName: activity.toolName, + preview: activity.preview, + startedAt: activity.startedAt, + source: activity.source, + }, + }); + } + + private finishRuntimeToolActivity( + run: ProvisioningRun, + toolUseId: string, + resultContent: unknown, + isError: boolean + ): void { + const active = run.activeToolCalls.get(toolUseId); + if (!active) return; + + run.activeToolCalls.delete(toolUseId); + this.emitToolActivity(run, { + action: 'finish', + memberName: active.memberName, + toolUseId, + finishedAt: nowIso(), + resultPreview: extractToolResultPreview(resultContent), + isError, + }); + } + + private resetRuntimeToolActivity(run: ProvisioningRun, memberName?: string): void { + if (run.activeToolCalls.size === 0) return; + + if (!memberName) { + run.activeToolCalls.clear(); + this.emitToolActivity(run, { action: 'reset' }); + return; + } + + let removed = false; + for (const [toolUseId, active] of run.activeToolCalls.entries()) { + if (active.memberName !== memberName) continue; + run.activeToolCalls.delete(toolUseId); + removed = true; + } + + if (removed) { + this.emitToolActivity(run, { action: 'reset', memberName }); + } + } + /** * Update spawn status for a specific team member and emit a change event. */ @@ -2951,6 +3059,7 @@ export class TeamProvisioningService { activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], + activeToolCalls: new Map(), pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, silentUserDmForward: null, @@ -3384,6 +3493,7 @@ export class TeamProvisioningService { activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], + activeToolCalls: new Map(), pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, silentUserDmForward: null, @@ -4955,6 +5065,7 @@ export class TeamProvisioningService { // The permission_request may arrive as plain JSON without wrapper, // and handleNativeTeammateUserMessage only processes blocks. const rawUserText = this.extractStreamUserText(msg); + const content = this.extractStreamContentBlocks(msg); if (rawUserText) { const perm = parsePermissionRequest(rawUserText); if (perm) { @@ -4969,20 +5080,22 @@ export class TeamProvisioningService { ); } } + for (const block of content) { + if (block?.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue; + this.finishRuntimeToolActivity( + run, + block.tool_use_id, + block.content, + block.is_error === true + ); + } this.handleNativeTeammateUserMessage(run, msg); return; } if (msg.type === 'assistant') { - const content = Array.isArray(msg.content) - ? (msg.content as Record[]) - : (() => { - const message = msg.message; - if (!message || typeof message !== 'object') return null; - const inner = (message as Record).content; - return Array.isArray(inner) ? (inner as Record[]) : null; - })(); + const content = this.extractStreamContentBlocks(msg); - const hasCapturedSendMessage = (content ?? []).some((part) => { + const hasCapturedSendMessage = content.some((part) => { if (!part || typeof part !== 'object') return false; if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false; const input = part.input; @@ -4991,7 +5104,7 @@ export class TeamProvisioningService { return typeof recipient === 'string' && recipient.trim().length > 0; }); - const textParts = (content ?? []) + const textParts = content .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text as string); if (textParts.length > 0) { @@ -5059,7 +5172,7 @@ export class TeamProvisioningService { // 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. // Works in both pre-ready and post-ready phases so early live messages get tool metadata. - for (const block of content ?? []) { + for (const block of content) { if ( block?.type === 'tool_use' && typeof block.name === 'string' && @@ -5069,19 +5182,21 @@ export class TeamProvisioningService { run.pendingToolCalls.push({ name: block.name, preview: extractToolPreview(block.name, input), + toolUseId: typeof block.id === 'string' ? block.id : undefined, }); + this.startRuntimeToolActivity(run, this.getRunLeadName(run), block); } } // Track member spawn events from Task tool_use blocks with team_name. // When the lead calls Task(team_name=X, name=Y), it means member Y is being spawned. - this.captureTeamSpawnEvents(run, content ?? []); + this.captureTeamSpawnEvents(run, content); // Capture SendMessage tool_use blocks from assistant output. // Works in both pre-ready and post-ready phases so outbound runtime messages // are visible in our team message artifacts even if Claude's own routing drifts. if (!run.silentUserDmForward || run.silentUserDmForward.mode === 'member_inbox_relay') { - this.captureSendMessages(run, content ?? []); + this.captureSendMessages(run, content); } // Extract context window usage from message.usage for real-time tracking. @@ -5100,12 +5215,24 @@ export class TeamProvisioningService { : 0; const cacheRead = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0; + // Total context window usage = all three token categories + // input_tokens = tokens AFTER last cache breakpoint (small) + // cache_creation = tokens written to cache (first request) + // cache_read = tokens read from cache (subsequent requests) — these ARE in context window const currentTokens = inputTokens + cacheCreation + cacheRead; if (!run.leadContextUsage) { + // Determine initial context window from model selection + // computeEffectiveTeamModel() defaults to 'opus[1m]' when no model selected + const modelStr = (run.request.model ?? '').toLowerCase(); + const isHaiku = modelStr.includes('haiku'); + const isLimitedContext = run.request.limitContext === true; + // limitContext=true → 200K, haiku → 200K, [1m] → 1M, default → 1M (opus[1m]) + const initialContextWindow = isLimitedContext || isHaiku ? 200_000 : 1_000_000; + run.leadContextUsage = { currentTokens, - contextWindow: 200_000, + contextWindow: initialContextWindow, lastUsageMessageId: msgId, lastEmittedAt: 0, }; @@ -5225,6 +5352,7 @@ export class TeamProvisioningService { ); } + this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); } if (run.pendingDirectCrossTeamSendRefresh) { @@ -5311,6 +5439,7 @@ export class TeamProvisioningService { `[${run.teamName}] post-compact reminder ${wasInFlight ? 'turn errored' : 'pending dropped'} — clearing (strict policy)` ); } + this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); } } @@ -5566,6 +5695,7 @@ export class TeamProvisioningService { } catch (error) { // Strict drop-after-attempt — do not re-arm. clearPostCompactReminderState(run); + this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); logger.warn( `[${run.teamName}] post-compact reminder injection failed: ${ @@ -6233,6 +6363,7 @@ export class TeamProvisioningService { } run.provisioningComplete = true; + this.resetRuntimeToolActivity(run, this.getRunLeadName(run)); this.setLeadActivity(run, 'idle'); // Clear provisioning timeout — no longer needed @@ -6697,6 +6828,7 @@ export class TeamProvisioningService { * Remove a run from tracking maps. */ private cleanupRun(run: ProvisioningRun): void { + this.resetRuntimeToolActivity(run); this.setLeadActivity(run, 'offline'); run.pendingDirectCrossTeamSendRefresh = false; if (run.timeoutHandle) { diff --git a/src/renderer/components/common/TokenUsageDisplay.tsx b/src/renderer/components/common/TokenUsageDisplay.tsx index 04e9ec7d..7bd72fbd 100644 --- a/src/renderer/components/common/TokenUsageDisplay.tsx +++ b/src/renderer/components/common/TokenUsageDisplay.tsx @@ -48,6 +48,8 @@ interface TokenUsageDisplayProps { totalPhases?: number; /** Optional USD cost for this usage */ costUsd?: number; + /** Context window size (e.g., 200000 or 1000000). When provided, shows "X% context used" instead of "X% of input". */ + contextWindowSize?: number; } /** @@ -57,9 +59,11 @@ interface TokenUsageDisplayProps { const SessionContextSection = ({ contextStats, totalInputTokens, + contextWindowSize, }: Readonly<{ contextStats: ContextStats; totalInputTokens: number; + contextWindowSize?: number; }>): React.JSX.Element => { const [expanded, setExpanded] = useState(false); @@ -67,11 +71,15 @@ const SessionContextSection = ({ // contextStats.totalEstimatedTokens already includes all categories (CLAUDE.md, @files, // tool outputs, thinking+text, task coordination, user messages) — no manual adjustment needed. - // Denominator is total input tokens only (not output), since visible context is part of input. + // Show context window usage % when contextWindowSize is available (more useful), + // otherwise fall back to visible context / total input ratio. const contextPercent = - totalInputTokens > 0 - ? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1) - : '0.0'; + contextWindowSize && contextWindowSize > 0 + ? Math.min((totalInputTokens / contextWindowSize) * 100, 100).toFixed(1) + : totalInputTokens > 0 + ? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1) + : '0.0'; + const contextLabel = contextWindowSize ? 'of context' : 'of input'; // Count accumulated injections by category const claudeMdCount = contextStats.accumulatedInjections.filter( @@ -144,7 +152,7 @@ const SessionContextSection = ({ className="whitespace-nowrap text-[10px] tabular-nums" style={{ color: COLOR_TEXT_MUTED }} > - {formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}%) + {formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% {contextLabel}) @@ -253,6 +261,7 @@ export const TokenUsageDisplay = ({ phaseNumber, totalPhases, costUsd, + contextWindowSize, }: Readonly): React.JSX.Element => { const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; // Total input tokens only (without output) — used as denominator for visible context % @@ -531,6 +540,7 @@ export const TokenUsageDisplay = ({ )} diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 483edf73..32252e9a 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -17,7 +17,12 @@ import type { GraphNodeState, GraphParticle, } from '@claude-teams/agent-graph'; -import type { InboxMessage, MemberSpawnStatusEntry, TeamData } from '@shared/types/team'; +import type { + ActiveToolCall, + InboxMessage, + MemberSpawnStatusEntry, + TeamData, +} from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; export class TeamGraphAdapter { @@ -51,7 +56,10 @@ export class TeamGraphAdapter { teamName: string, spawnStatuses?: Record, leadContext?: LeadContextUsage, - pendingApprovalAgents?: Set + pendingApprovalAgents?: Set, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); @@ -62,7 +70,44 @@ export class TeamGraphAdapter { const approvalKey = pendingApprovalAgents?.size ? Array.from(pendingApprovalAgents).sort().join(',') : ''; - const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}`; + const activeToolKey = activeTools + ? Object.entries(activeTools) + .flatMap(([memberName, tools]) => + Object.values(tools).map( + (tool) => + `${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + ) + .sort() + .join('|') + : ''; + const finishedVisibleKey = finishedVisible + ? Object.entries(finishedVisible) + .flatMap(([memberName, tools]) => + Object.values(tools).map( + (tool) => + `${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + ) + .sort() + .join('|') + : ''; + const historyKey = toolHistory + ? Object.entries(toolHistory) + .map( + ([memberName, tools]) => + `${memberName}:${tools + .slice(0, 3) + .map( + (tool) => + `${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + .join(',')}` + ) + .sort() + .join('|') + : ''; + const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}`; if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { return this.#cachedResult; } @@ -84,8 +129,19 @@ export class TeamGraphAdapter { const particles: GraphParticle[] = []; const leadId = `lead:${teamName}`; + const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); - this.#buildLeadNode(nodes, leadId, teamData, teamName, leadContext); + this.#buildLeadNode( + nodes, + leadId, + teamData, + teamName, + leadName, + leadContext, + activeTools, + finishedVisible, + toolHistory + ); this.#buildMemberNodes( nodes, edges, @@ -93,7 +149,10 @@ export class TeamGraphAdapter { teamData, teamName, spawnStatuses, - pendingApprovalAgents + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory ); this.#buildTaskNodes(nodes, edges, teamData, teamName); this.#buildProcessNodes(nodes, edges, teamData, teamName); @@ -126,23 +185,75 @@ export class TeamGraphAdapter { // ─── Private: node builders ────────────────────────────────────────────── + static #getLeadMemberName(data: TeamData, teamName: string): string { + return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; + } + + static #selectVisibleTool( + runningTools?: Record, + finishedTools?: Record + ): ActiveToolCall | undefined { + const newestRunning = Object.values(runningTools ?? {}).sort((a, b) => + b.startedAt.localeCompare(a.startedAt) + )[0]; + if (newestRunning) return newestRunning; + return Object.values(finishedTools ?? {}).sort((a, b) => + (b.finishedAt ?? '').localeCompare(a.finishedAt ?? '') + )[0]; + } + #buildLeadNode( nodes: GraphNode[], leadId: string, data: TeamData, teamName: string, - leadContext?: LeadContextUsage + leadName: string, + leadContext?: LeadContextUsage, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record ): void { const percent = leadContext?.percent; + const activeTool = TeamGraphAdapter.#selectVisibleTool( + activeTools?.[leadName], + finishedVisible?.[leadName] + ); nodes.push({ id: leadId, kind: 'lead', label: data.config.name || teamName, - state: data.isAlive ? 'active' : 'idle', + state: !data.isAlive + ? 'idle' + : Object.keys(activeTools?.[leadName] ?? {}).length > 0 + ? 'tool_calling' + : 'active', color: data.config.color ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, - avatarUrl: agentAvatarUrl('team-lead', 64), - domainRef: { kind: 'lead', teamName }, + avatarUrl: agentAvatarUrl(leadName, 64), + activeTool: activeTool + ? { + name: activeTool.toolName, + preview: activeTool.preview, + state: activeTool.state, + startedAt: activeTool.startedAt, + finishedAt: activeTool.finishedAt, + resultPreview: activeTool.resultPreview, + source: activeTool.source, + } + : undefined, + recentTools: (toolHistory?.[leadName] ?? []) + .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) + .slice(0, 3) + .map((tool) => ({ + name: tool.toolName, + preview: tool.preview, + state: tool.state === 'error' ? 'error' : 'complete', + startedAt: tool.startedAt, + finishedAt: tool.finishedAt!, + resultPreview: tool.resultPreview, + source: tool.source, + })), + domainRef: { kind: 'lead', teamName, memberName: leadName }, }); } @@ -153,7 +264,10 @@ export class TeamGraphAdapter { data: TeamData, teamName: string, spawnStatuses?: Record, - pendingApprovalAgents?: Set + pendingApprovalAgents?: Set, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record ): void { for (const member of data.members) { if (member.removedAt) continue; @@ -161,12 +275,19 @@ export class TeamGraphAdapter { const memberId = `member:${teamName}:${member.name}`; const spawn = spawnStatuses?.[member.name]; + const activeTool = TeamGraphAdapter.#selectVisibleTool( + activeTools?.[member.name], + finishedVisible?.[member.name] + ); + const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0; nodes.push({ id: memberId, kind: 'member', label: member.name, - state: TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status), + state: hasRunningTool + ? 'tool_calling' + : TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status), color: member.color ?? undefined, role: member.role ?? undefined, spawnStatus: spawn?.status, @@ -176,6 +297,29 @@ export class TeamGraphAdapter { ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject : undefined, pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, + activeTool: activeTool + ? { + name: activeTool.toolName, + preview: activeTool.preview, + state: activeTool.state, + startedAt: activeTool.startedAt, + finishedAt: activeTool.finishedAt, + resultPreview: activeTool.resultPreview, + source: activeTool.source, + } + : undefined, + recentTools: (toolHistory?.[member.name] ?? []) + .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) + .slice(0, 3) + .map((tool) => ({ + name: tool.toolName, + preview: tool.preview, + state: tool.state === 'error' ? 'error' : 'complete', + startedAt: tool.startedAt, + finishedAt: tool.finishedAt!, + resultPreview: tool.resultPreview, + source: tool.source, + })), domainRef: { kind: 'member', teamName, memberName: member.name }, }); diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts index 55f36f6f..6356d0c4 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -15,12 +15,23 @@ import type { GraphDataPort } from '@claude-teams/agent-graph'; export function useTeamGraphAdapter(teamName: string): GraphDataPort { const adapterRef = useRef(TeamGraphAdapter.create()); - const { teamData, spawnStatuses, leadContext, pendingApprovals } = useStore( + const { + teamData, + spawnStatuses, + leadContext, + pendingApprovals, + activeTools, + finishedVisible, + toolHistory, + } = useStore( useShallow((s) => ({ teamData: s.selectedTeamData, spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, pendingApprovals: s.pendingApprovals, + activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, + finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined, + toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, })) ); @@ -39,8 +50,20 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { teamName, spawnStatuses, leadContext, - pendingApprovalAgents + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory ), - [teamData, teamName, spawnStatuses, leadContext, pendingApprovalAgents] + [ + teamData, + teamName, + spawnStatuses, + leadContext, + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory, + ] ); } diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 520ccfd3..72d89ab1 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -80,7 +80,10 @@ function MemberPopoverContent({ onCreateTask?: (owner: string) => void; onOpenTask?: (taskId: string) => void; }): React.JSX.Element { - const memberName = node.domainRef.kind === 'member' ? node.domainRef.memberName : 'team-lead'; + const memberName = + node.domainRef.kind === 'member' || node.domainRef.kind === 'lead' + ? node.domainRef.memberName + : 'team-lead'; const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); const statusLabel = node.state === 'active' @@ -89,7 +92,9 @@ function MemberPopoverContent({ ? 'Idle' : node.state === 'terminated' ? 'Offline' - : node.state; + : node.state === 'tool_calling' + ? 'Running tool' + : node.state; const statusDotColor = node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling' @@ -199,6 +204,67 @@ function MemberPopoverContent({ )} + {node.activeTool && ( +
+
+ + + {node.activeTool.state === 'running' + ? 'Running tool' + : node.activeTool.state === 'error' + ? 'Tool failed' + : 'Tool finished'} + +
+
+ {node.activeTool.preview + ? `${node.activeTool.name}: ${node.activeTool.preview}` + : node.activeTool.name} +
+ {node.activeTool.resultPreview && node.activeTool.state !== 'running' && ( +
+ {node.activeTool.resultPreview} +
+ )} +
+ )} + + {node.recentTools && node.recentTools.length > 0 && ( +
+
+ Recent tools +
+
+ {node.recentTools.slice(0, 3).map((tool) => ( +
+
+ {tool.preview ? `${tool.name}: ${tool.preview}` : tool.name} +
+ {tool.resultPreview && ( +
{tool.resultPreview}
+ )} +
+ ))} +
+
+ )} + {/* Actions */}