feat(agent-graph): show teammate runtime labels
This commit is contained in:
parent
21e9fb8c90
commit
433bdf8bbc
7 changed files with 228 additions and 34 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
85
src/renderer/utils/teamRuntimeSummary.ts
Normal file
85
src/renderer/utils/teamRuntimeSummary.ts
Normal 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(' · ');
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue