diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 44bdd28d..97c4c27c 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -6,7 +6,13 @@ import type { GraphNode } from '../ports/types'; import { COLORS, getStateColor, alphaHex } from '../constants/colors'; -import { NODE, AGENT_DRAW, CONTEXT_RING, ANIM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; +import { + NODE, + AGENT_DRAW, + CONTEXT_RING, + ANIM, + MIN_VISIBLE_OPACITY, +} from '../constants/canvas-constants'; import { drawHexagon } from './draw-misc'; import { getAgentGlowSprite, ensureHex, hexWithAlpha } from './render-cache'; @@ -18,7 +24,7 @@ export function drawAgents( nodes: GraphNode[], time: number, selectedId: string | null, - hoveredId: string | null, + hoveredId: string | null ): void { for (const node of nodes) { if (node.kind !== 'member' && node.kind !== 'lead') continue; @@ -48,7 +54,7 @@ export function drawAgents( drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl); // Breathing animation + spawn/waiting effects - drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); + drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel); // Pending approval indicator: pulsing amber ring if (node.pendingApproval) { @@ -72,7 +78,10 @@ export function drawAgents( } // Working indicator: subtle spinning arc when member has active task - if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) { + if ( + node.currentTaskId && + (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling') + ) { const ringR = r + 4; const rotation = time * 1.5; ctx.beginPath(); @@ -88,7 +97,7 @@ export function drawAgents( // Name + role label (single line: "jack · developer") const labelText = node.role ? `${node.label} · ${node.role}` : node.label; - drawLabel(ctx, x, y, r, labelText, color); + drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel); // TODO: Context ring disabled — LeadContextUsage.percent is unreliable // (jumps due to cache_read variance, contextWindow mismatch with actual model). @@ -114,7 +123,7 @@ export function drawCrossTeamNodes( nodes: GraphNode[], time: number, selectedId: string | null, - hoveredId: string | null, + hoveredId: string | null ): void { for (const node of nodes) { if (node.kind !== 'crossteam') continue; @@ -191,7 +200,13 @@ function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: ctx.restore(); } -function drawGlow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string): void { +function drawGlow( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string +): void { const outerR = r + AGENT_DRAW.glowPadding; const sprite = getAgentGlowSprite(color, r * 0.5, outerR); ctx.drawImage(sprite, x - outerR, y - outerR); @@ -206,19 +221,18 @@ function drawHexBody( state: string, time: number, isSelected: boolean, - isHovered: boolean, + isHovered: boolean ): void { // Interior fill drawHexagon(ctx, x, y, r); - ctx.fillStyle = isSelected - ? 'rgba(100, 200, 255, 0.15)' - : COLORS.nodeInterior; + ctx.fillStyle = isSelected ? 'rgba(100, 200, 255, 0.15)' : COLORS.nodeInterior; ctx.fill(); // Scanline effect - const scanSpeed = state === 'active' || state === 'thinking' || state === 'tool_calling' - ? ANIM.scanline.active - : ANIM.scanline.normal; + const scanSpeed = + state === 'active' || state === 'thinking' || state === 'tool_calling' + ? ANIM.scanline.active + : ANIM.scanline.normal; const scanY = ((time * scanSpeed) % (r * 2)) - r; ctx.save(); drawHexagon(ctx, x, y, r); @@ -227,7 +241,7 @@ function drawHexBody( x, y + scanY - AGENT_DRAW.scanlineHalfH, x, - y + scanY + AGENT_DRAW.scanlineHalfH, + y + scanY + AGENT_DRAW.scanlineHalfH ); grad.addColorStop(0, hexWithAlpha(color, 0)); grad.addColorStop(0.5, hexWithAlpha(color, 0.13)); @@ -243,11 +257,7 @@ function drawHexBody( ctx.stroke(); } -function truncateCardText( - ctx: CanvasRenderingContext2D, - text: string, - maxWidth: number, -): string { +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) { @@ -262,7 +272,7 @@ function drawToolCard( y: number, r: number, tool: NonNullable, - time: number, + time: number ): void { const labelBase = tool.preview ? `${tool.name}: ${tool.preview}` : tool.name; const labelText = @@ -300,13 +310,7 @@ function drawToolCard( if (tool.state === 'running') { ctx.beginPath(); - ctx.arc( - indicatorX, - indicatorY, - 4.5, - time * 3, - time * 3 + Math.PI * 1.2, - ); + ctx.arc(indicatorX, indicatorY, 4.5, time * 3, time * 3 + Math.PI * 1.2); ctx.strokeStyle = accent; ctx.lineWidth = 1.4; ctx.stroke(); @@ -332,7 +336,11 @@ function drawBreathing( state: string, time: number, spawnStatus?: GraphNode['spawnStatus'], + runtimeLabel?: string ): void { + const hasRuntimeLabel = Boolean(runtimeLabel?.trim()); + const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14); + // Spawning: bright animated double ring + radial glow if (spawnStatus === 'spawning') { const ringR = r + AGENT_DRAW.orbitParticleOffset; @@ -370,7 +378,7 @@ function drawBreathing( ctx.font = '7px monospace'; ctx.textAlign = 'center'; ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2)); - ctx.fillText('connecting...', x, y + r + AGENT_DRAW.labelYOffset + 14); + ctx.fillText('connecting...', x, serviceLabelY); return; } @@ -397,7 +405,7 @@ function drawBreathing( ctx.font = '7px monospace'; ctx.textAlign = 'center'; ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5)); - ctx.fillText('waiting...', x, y + r + AGENT_DRAW.labelYOffset + 14); + ctx.fillText('waiting...', x, serviceLabelY); return; } @@ -473,7 +481,7 @@ function drawAvatar( name: string, color: string, isLead: boolean, - avatarUrl?: string, + avatarUrl?: string ): void { const avatarR = r * 0.6; @@ -509,6 +517,7 @@ function drawLabel( r: number, label: string, color: string, + runtimeLabel?: string ): void { const labelY = y + r + AGENT_DRAW.labelYOffset; ctx.font = '9px monospace'; @@ -516,6 +525,26 @@ function drawLabel( ctx.textBaseline = 'top'; ctx.fillStyle = color; ctx.fillText(label, x, labelY); + + const trimmedRuntimeLabel = runtimeLabel?.trim(); + if (!trimmedRuntimeLabel) { + return; + } + + ctx.font = '8px monospace'; + ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); + ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10); +} + +function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string { + const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2); + if (ctx.measureText(label).width <= maxWidth) return label; + + let out = label; + while (out.length > 1 && ctx.measureText(`${out}…`).width > maxWidth) { + out = out.slice(0, -1); + } + return `${out}…`; } /** @@ -527,7 +556,7 @@ export function drawContextRing( y: number, r: number, usage: number, - time: number, + time: number ): void { const ringR = r + CONTEXT_RING.ringOffset; const startAngle = -Math.PI / 2; @@ -576,7 +605,7 @@ function drawSelectionRing( x: number, y: number, r: number, - color: string, + color: string ): void { drawHexagon(ctx, x, y, r + 4); ctx.strokeStyle = hexWithAlpha(color, 0.67); diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 8bcc43f0..a7b96067 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -44,6 +44,8 @@ export interface GraphNode { // ─── Member/Lead-specific ────────────────────────────────────────────── /** Agent role description */ role?: string; + /** Compact provider/model/effort summary shown under the label */ + runtimeLabel?: string; /** Avatar image URL (e.g., robohash) */ avatarUrl?: string; /** Spawn lifecycle status */ diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 4481cf4b..8b1b3e5d 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -8,6 +8,7 @@ */ import { getUnreadCount } from '@renderer/services/commentReadStorage'; +import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { stripCrossTeamPrefix } from '@shared/constants/crossTeam'; import { @@ -78,7 +79,7 @@ export class TeamGraphAdapter { const memberKey = teamData.members .map( (member) => - `${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.removedAt ?? ''}` + `${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.providerId ?? ''}:${member.model ?? ''}:${member.effort ?? ''}:${member.removedAt ?? ''}` ) .sort() .join('|'); @@ -241,6 +242,14 @@ export class TeamGraphAdapter { return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; } + static #getRuntimeLabel( + providerId: TeamData['members'][number]['providerId'], + model: TeamData['members'][number]['model'], + effort: TeamData['members'][number]['effort'] + ): string | undefined { + return formatTeamRuntimeSummary(providerId, model, effort); + } + static #selectVisibleTool( runningTools?: Record, finishedTools?: Record @@ -266,6 +275,7 @@ export class TeamGraphAdapter { toolHistory?: Record ): void { const percent = leadContext?.percent; + const leadMember = data.members.find((member) => member.name === leadName); const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[leadName], finishedVisible?.[leadName] @@ -280,6 +290,11 @@ export class TeamGraphAdapter { ? 'tool_calling' : 'active', color: data.config.color ?? undefined, + runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( + leadMember?.providerId, + leadMember?.model, + leadMember?.effort + ), contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, avatarUrl: agentAvatarUrl(leadName, 64), activeTool: activeTool @@ -342,6 +357,11 @@ export class TeamGraphAdapter { : TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status), color: member.color ?? undefined, role: member.role ?? undefined, + runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( + member.providerId, + member.model, + member.effort + ), spawnStatus: spawn?.status, avatarUrl: agentAvatarUrl(member.name, 64), currentTaskId: member.currentTaskId ?? undefined, diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 6fc18a8e..9e31fdd2 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -232,6 +232,11 @@ const MemberPopoverContent = ({ {node.role && (
{node.role}
)} + {node.runtimeLabel && ( +
+ {node.runtimeLabel} +
+ )} diff --git a/src/renderer/utils/teamRuntimeSummary.ts b/src/renderer/utils/teamRuntimeSummary.ts new file mode 100644 index 00000000..f81c11c0 --- /dev/null +++ b/src/renderer/utils/teamRuntimeSummary.ts @@ -0,0 +1,85 @@ +import type { TeamProviderId } from '@shared/types'; + +const MODEL_LABEL_OVERRIDES: Record = { + default: 'Default', + 'claude-sonnet-4-6': 'Sonnet 4.6', + 'claude-sonnet-4-6[1m]': 'Sonnet 4.6 (1M)', + 'claude-opus-4-6': 'Opus 4.6', + 'claude-opus-4-6[1m]': 'Opus 4.6 (1M)', + 'claude-haiku-4-5-20251001': 'Haiku 4.5', + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-5.3-codex': 'GPT-5.3 Codex', + 'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark', + 'gpt-5.2': 'GPT-5.2', + 'gpt-5.2-codex': 'GPT-5.2 Codex', + 'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini', + 'gpt-5.1-codex-max': 'GPT-5.1 Codex Max', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite', +}; + +export function getTeamRuntimeModelLabel(model: string | undefined): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) return undefined; + return MODEL_LABEL_OVERRIDES[trimmed] ?? trimmed; +} + +export function getTeamRuntimeProviderLabel( + providerId: TeamProviderId | undefined +): string | undefined { + switch (providerId) { + case 'codex': + return 'Codex'; + case 'gemini': + return 'Gemini'; + case 'anthropic': + return 'Anthropic'; + default: + return undefined; + } +} + +export function getTeamRuntimeEffortLabel(effort: string | undefined): string | undefined { + const trimmed = effort?.trim(); + if (!trimmed) return undefined; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +export function formatTeamRuntimeSummary( + providerId: TeamProviderId | undefined, + model: string | undefined, + effort?: string +): string | undefined { + const providerLabel = getTeamRuntimeProviderLabel(providerId); + const modelLabel = getTeamRuntimeModelLabel(model); + const effortLabel = getTeamRuntimeEffortLabel(effort); + + if (!providerLabel && !modelLabel && !effortLabel) { + return undefined; + } + + const normalizedProvider = providerLabel?.trim().toLowerCase(); + const normalizedModel = modelLabel?.trim().toLowerCase(); + const modelAlreadyCarriesProviderBrand = + Boolean(modelLabel) && + Boolean(normalizedProvider) && + Boolean(normalizedModel) && + (normalizedModel!.startsWith(normalizedProvider!) || + (providerId === 'anthropic' && normalizedModel!.startsWith('claude')) || + (providerId === 'codex' && + (normalizedModel!.startsWith('codex') || normalizedModel!.startsWith('gpt'))) || + (providerId === 'gemini' && normalizedModel!.startsWith('gemini'))); + + const providerActsAsBackendOnly = + providerId !== 'anthropic' && Boolean(modelLabel) && !modelAlreadyCarriesProviderBrand; + + const parts = modelAlreadyCarriesProviderBrand + ? [modelLabel, effortLabel] + : providerActsAsBackendOnly + ? [modelLabel, `via ${providerLabel}`, effortLabel] + : [providerLabel, providerLabel && !modelLabel ? 'Default' : modelLabel, effortLabel]; + + return parts.filter(Boolean).join(' · '); +} diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index 24410ad6..ce765451 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -26,6 +26,7 @@ function makeMemberNode(spawnStatus: GraphNode['spawnStatus']): GraphNode { kind: 'member', label: 'alice', role: 'Reviewer', + runtimeLabel: 'Codex · GPT-5.4 Mini · Medium', state: 'idle', color: '#60a5fa', avatarUrl: undefined, @@ -70,6 +71,7 @@ describe('GraphNodePopover spawn badge labels', () => { }); expect(host.textContent).toContain('starting'); + expect(host.textContent).toContain('Codex · GPT-5.4 Mini · Medium'); expect(host.textContent).not.toContain('waiting'); expect(host.textContent).not.toContain('spawning'); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 389b7d5c..497e22ba 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -501,4 +501,55 @@ describe('TeamGraphAdapter particles', () => { const alice = graph.nodes.find((node) => node.id === 'member:my-team:alice'); expect(alice?.state).toBe('idle'); }); + + it('adds compact runtime labels for lead and members and refreshes when runtime changes', () => { + const adapter = TeamGraphAdapter.create(); + adapter.adapt(createBaseTeamData(), 'my-team'); + + const graph = adapter.adapt( + createBaseTeamData({ + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }, + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + providerId: 'anthropic', + model: 'sonnet', + effort: 'high', + }, + { + name: 'bob', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }, + ], + }), + 'my-team' + ); + + expect(graph.nodes.find((node) => node.id === 'lead:my-team')?.runtimeLabel).toBe( + 'GPT-5.4 Mini · Medium' + ); + expect(graph.nodes.find((node) => node.id === 'member:my-team:alice')?.runtimeLabel).toBe( + 'Anthropic · sonnet · High' + ); + }); });