feat(agent-graph): show teammate runtime labels

This commit is contained in:
iliya 2026-04-09 21:16:49 +03:00
parent 21e9fb8c90
commit 433bdf8bbc
7 changed files with 228 additions and 34 deletions

View file

@ -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<GraphNode['activeTool']>,
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);

View file

@ -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 */

View file

@ -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<string, ActiveToolCall>,
finishedTools?: Record<string, ActiveToolCall>
@ -266,6 +275,7 @@ export class TeamGraphAdapter {
toolHistory?: Record<string, ActiveToolCall[]>
): 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,

View file

@ -232,6 +232,11 @@ const MemberPopoverContent = ({
{node.role && (
<div className="truncate text-xs text-[var(--color-text-muted)]">{node.role}</div>
)}
{node.runtimeLabel && (
<div className="truncate text-[11px] text-[var(--color-text-muted)]">
{node.runtimeLabel}
</div>
)}
</div>
</div>

View file

@ -0,0 +1,85 @@
import type { TeamProviderId } from '@shared/types';
const MODEL_LABEL_OVERRIDES: Record<string, string> = {
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(' · ');
}

View file

@ -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');

View file

@ -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'
);
});
});