fix(agent-graph): improve stopped team visuals

This commit is contained in:
777genius 2026-04-28 15:00:57 +03:00
parent e87ef2dd85
commit 95da573081
4 changed files with 223 additions and 25 deletions

View file

@ -14,7 +14,7 @@ import {
MIN_VISIBLE_OPACITY,
} from '../constants/canvas-constants';
import { drawHexagon } from './draw-misc';
import { getAgentGlowSprite, ensureHex, hexWithAlpha } from './render-cache';
import { getAgentGlowSprite, hexWithAlpha } from './render-cache';
/**
* Draw all member/lead nodes on the canvas.
@ -128,7 +128,6 @@ export function drawAgents(
y,
r,
labelText,
color,
node.runtimeLabel,
node.launchStatusLabel,
node.launchVisualState
@ -688,17 +687,15 @@ function drawLabel(
y: number,
r: number,
label: string,
color: string,
runtimeLabel?: string,
launchStatusLabel?: string,
launchVisualState?: GraphNode['launchVisualState']
): void {
const labelY = y + r + AGENT_DRAW.labelYOffset;
ctx.font = '9px monospace';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
drawLabelText(ctx, label, x, labelY, '#e8f8ff', 12);
const trimmedRuntimeLabel = runtimeLabel?.trim();
const trimmedLaunchStatusLabel = launchStatusLabel?.trim();
@ -706,21 +703,68 @@ function drawLabel(
return;
}
let nextLineY = labelY + 10;
let nextLineY = labelY + 11;
if (trimmedRuntimeLabel) {
ctx.font = '8px monospace';
ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY);
drawLabelText(ctx, truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY, '#b9d7f2', 10);
nextLineY += 10;
}
if (trimmedLaunchStatusLabel) {
ctx.font = '7px monospace';
ctx.fillStyle = getLaunchStatusColor(launchVisualState);
ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY);
drawLabelText(
ctx,
truncateSubLabel(ctx, trimmedLaunchStatusLabel, r),
x,
nextLineY,
getLaunchStatusColor(launchVisualState),
9
);
}
}
function drawLabelText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
fillStyle: string,
lineHeight: number
): void {
const textWidth = ctx.measureText(text).width;
const paddingX = 5;
const paddingY = 1.5;
ctx.save();
ctx.globalAlpha = Math.max(ctx.globalAlpha, 0.88);
ctx.beginPath();
ctx.roundRect(
x - textWidth / 2 - paddingX,
y - paddingY,
textWidth + paddingX * 2,
lineHeight,
4
);
ctx.fillStyle = 'rgba(2, 6, 23, 0.78)';
ctx.fill();
ctx.strokeStyle = 'rgba(148, 213, 255, 0.18)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = fillStyle;
drawTextWithHalo(ctx, text, x, y);
ctx.restore();
}
function drawTextWithHalo(ctx: CanvasRenderingContext2D, text: string, x: number, y: number): void {
ctx.save();
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.96)';
ctx.strokeText(text, x, y);
ctx.restore();
ctx.fillText(text, x, y);
}
function truncateSubLabel(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;

View file

@ -415,6 +415,7 @@ export class TeamGraphAdapter {
): void {
const percent = leadContext?.contextUsedPercent;
const leadMember = data.members.find((member) => member.name === leadName);
const isTeamVisualOnline = data.isAlive || isTeamProvisioning;
const activeTool = TeamGraphAdapter.#selectVisibleTool(
activeTools?.[leadName],
finishedVisible?.[leadName]
@ -437,7 +438,7 @@ export class TeamGraphAdapter {
})
: null;
const leadState =
leadActivity === 'offline'
!isTeamVisualOnline || leadActivity === 'offline'
? 'terminated'
: leadActivity === 'idle'
? 'idle'
@ -445,7 +446,7 @@ export class TeamGraphAdapter {
? 'tool_calling'
: 'active';
const leadException =
leadActivity === 'offline'
!isTeamVisualOnline || leadActivity === 'offline'
? { exceptionTone: 'error' as const, exceptionLabel: 'offline' }
: pendingApproval
? { exceptionTone: 'warning' as const, exceptionLabel: 'awaiting approval' }
@ -455,7 +456,7 @@ export class TeamGraphAdapter {
kind: 'lead',
label: data.config.name || teamName,
state: leadState,
color: data.config.color ?? undefined,
color: isTeamVisualOnline ? (data.config.color ?? undefined) : undefined,
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
leadMember?.providerId,
leadMember?.model,
@ -516,6 +517,7 @@ export class TeamGraphAdapter {
if (member.removedAt) continue;
if (isLeadMember(member)) continue;
const isTeamVisualOnline = data.isAlive || isTeamProvisioning;
const memberId =
memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member);
const spawn = spawnStatuses?.[member.name];
@ -546,19 +548,25 @@ export class TeamGraphAdapter {
id: memberId,
kind: 'member',
label: member.name,
state: hasRunningTool
state: !isTeamVisualOnline
? 'terminated'
: hasRunningTool
? 'tool_calling'
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn),
color: member.color ?? undefined,
color: isTeamVisualOnline ? (member.color ?? undefined) : undefined,
role: member.role ?? undefined,
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
member.providerId,
member.model,
member.effort
),
spawnStatus: spawn?.status,
launchVisualState: launchPresentation.launchVisualState ?? undefined,
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
spawnStatus: isTeamVisualOnline ? spawn?.status : undefined,
launchVisualState: isTeamVisualOnline
? (launchPresentation.launchVisualState ?? undefined)
: undefined,
launchStatusLabel: isTeamVisualOnline
? (launchPresentation.launchStatusLabel ?? undefined)
: undefined,
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
currentTaskId: member.currentTaskId ?? undefined,
currentTaskSubject: member.currentTaskId

View file

@ -1433,6 +1433,77 @@ describe('TeamGraphAdapter particles', () => {
});
});
it('uses one offline visual state for lead and members when the team is stopped', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
isAlive: false,
config: {
name: 'My Team',
color: '#22d3ee',
members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }],
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'alice',
status: 'active',
color: '#0000ff',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'bob',
status: 'idle',
color: '#ffcc00',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
}),
'my-team',
{
alice: {
status: 'waiting',
launchState: 'starting',
updatedAt: '2026-04-08T20:00:00.000Z',
},
},
'active'
);
expect(findNode(graph, 'lead:my-team')).toMatchObject({
state: 'terminated',
color: undefined,
exceptionTone: 'error',
exceptionLabel: 'offline',
});
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
state: 'terminated',
color: undefined,
spawnStatus: undefined,
launchVisualState: undefined,
launchStatusLabel: undefined,
});
expect(findNode(graph, 'member:my-team:bob')).toMatchObject({
state: 'terminated',
color: undefined,
});
});
it('treats literal lead approval sources as lead-node pending approvals', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(

View file

@ -19,12 +19,17 @@ interface FillTextCall {
text: string;
x: number;
y: number;
fillStyle: string;
globalAlpha: number;
}
function createMockContext() {
const fillTextCalls: FillTextCall[] = [];
const strokeTextCalls: FillTextCall[] = [];
const roundRectCalls: Array<{ x: number; y: number; width: number; height: number }> = [];
const gradient = { addColorStop: vi.fn() };
let fillStyle = '';
let globalAlpha = 1;
const ctx = {
save: vi.fn(),
@ -51,22 +56,35 @@ function createMockContext() {
createLinearGradient: vi.fn(() => gradient),
measureText: vi.fn((text: string) => ({ width: text.length * 4.5 })),
fillText: vi.fn((text: string, x: number, y: number) => {
fillTextCalls.push({ text, x, y });
fillTextCalls.push({ text, x, y, fillStyle, globalAlpha });
}),
strokeText: vi.fn((text: string, x: number, y: number) => {
strokeTextCalls.push({ text, x, y, fillStyle, globalAlpha });
}),
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
fillStyle: '',
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string) {
fillStyle = value;
},
strokeStyle: '',
lineWidth: 1,
font: '',
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline,
globalAlpha: 1,
get globalAlpha() {
return globalAlpha;
},
set globalAlpha(value: number) {
globalAlpha = value;
},
} as unknown as CanvasRenderingContext2D;
return { ctx, fillTextCalls, roundRectCalls };
return { ctx, fillTextCalls, strokeTextCalls, roundRectCalls };
}
describe('drawAgents', () => {
@ -140,4 +158,61 @@ describe('drawAgents', () => {
expect(fillTextCalls.some((call) => call.text === 'waiting...')).toBe(false);
expect(fillTextCalls.some((call) => call.text === 'connecting...')).toBe(false);
});
it('draws member labels with fixed high-contrast text and backdrops', () => {
const { ctx, fillTextCalls, strokeTextCalls, roundRectCalls } = createMockContext();
const node: GraphNode = {
id: 'member:demo:alice',
kind: 'member',
label: 'alice',
role: 'reviewer',
state: 'idle',
color: '#0000ff',
runtimeLabel: 'Anthropic · Opus 4.6',
domainRef: { kind: 'member', teamName: 'demo', memberName: 'alice' },
x: 320,
y: 240,
};
drawAgents(ctx, [node], 0, null, null, null, 1);
const labelCall = fillTextCalls.find((call) => call.text === 'alice · reviewer');
const runtimeCall = fillTextCalls.find((call) => call.text.includes('Anthropic'));
expect(labelCall).toBeDefined();
expect(runtimeCall).toBeDefined();
expect(labelCall?.fillStyle).toBe('#e8f8ff');
expect(runtimeCall?.fillStyle).toBe('#b9d7f2');
expect(roundRectCalls.filter((call) => call.height === 12 || call.height === 10)).toHaveLength(
2
);
expect(strokeTextCalls.some((call) => call.text === 'alice · reviewer')).toBe(true);
expect(strokeTextCalls.some((call) => call.text.includes('Anthropic'))).toBe(true);
});
it('keeps lead labels readable when the lead node is visually dimmed', () => {
const { ctx, fillTextCalls, roundRectCalls } = createMockContext();
const node: GraphNode = {
id: 'lead:demo',
kind: 'lead',
label: 'signal-ops-12',
state: 'terminated',
color: '#0000ff',
runtimeLabel: 'GPT-5.4',
domainRef: { kind: 'lead', teamName: 'demo', memberName: 'signal-ops-12' },
x: 320,
y: 240,
};
drawAgents(ctx, [node], 0, null, null, null, 1);
const labelCall = fillTextCalls.find((call) => call.text === 'signal-ops-12');
const runtimeCall = fillTextCalls.find((call) => call.text === 'GPT-5.4');
expect(labelCall).toMatchObject({ fillStyle: '#e8f8ff', globalAlpha: 0.88 });
expect(runtimeCall).toMatchObject({ fillStyle: '#b9d7f2', globalAlpha: 0.88 });
expect(roundRectCalls.filter((call) => call.height === 12 || call.height === 10)).toHaveLength(
2
);
});
});