fix(agent-graph): improve stopped team visuals
This commit is contained in:
parent
e87ef2dd85
commit
95da573081
4 changed files with 223 additions and 25 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? 'tool_calling'
|
||||
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn),
|
||||
color: member.color ?? undefined,
|
||||
state: !isTeamVisualOnline
|
||||
? 'terminated'
|
||||
: hasRunningTool
|
||||
? 'tool_calling'
|
||||
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn),
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue