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,
|
MIN_VISIBLE_OPACITY,
|
||||||
} from '../constants/canvas-constants';
|
} from '../constants/canvas-constants';
|
||||||
import { drawHexagon } from './draw-misc';
|
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.
|
* Draw all member/lead nodes on the canvas.
|
||||||
|
|
@ -128,7 +128,6 @@ export function drawAgents(
|
||||||
y,
|
y,
|
||||||
r,
|
r,
|
||||||
labelText,
|
labelText,
|
||||||
color,
|
|
||||||
node.runtimeLabel,
|
node.runtimeLabel,
|
||||||
node.launchStatusLabel,
|
node.launchStatusLabel,
|
||||||
node.launchVisualState
|
node.launchVisualState
|
||||||
|
|
@ -688,17 +687,15 @@ function drawLabel(
|
||||||
y: number,
|
y: number,
|
||||||
r: number,
|
r: number,
|
||||||
label: string,
|
label: string,
|
||||||
color: string,
|
|
||||||
runtimeLabel?: string,
|
runtimeLabel?: string,
|
||||||
launchStatusLabel?: string,
|
launchStatusLabel?: string,
|
||||||
launchVisualState?: GraphNode['launchVisualState']
|
launchVisualState?: GraphNode['launchVisualState']
|
||||||
): void {
|
): void {
|
||||||
const labelY = y + r + AGENT_DRAW.labelYOffset;
|
const labelY = y + r + AGENT_DRAW.labelYOffset;
|
||||||
ctx.font = '9px monospace';
|
ctx.font = 'bold 10px monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.fillStyle = color;
|
drawLabelText(ctx, label, x, labelY, '#e8f8ff', 12);
|
||||||
ctx.fillText(label, x, labelY);
|
|
||||||
|
|
||||||
const trimmedRuntimeLabel = runtimeLabel?.trim();
|
const trimmedRuntimeLabel = runtimeLabel?.trim();
|
||||||
const trimmedLaunchStatusLabel = launchStatusLabel?.trim();
|
const trimmedLaunchStatusLabel = launchStatusLabel?.trim();
|
||||||
|
|
@ -706,21 +703,68 @@ function drawLabel(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextLineY = labelY + 10;
|
let nextLineY = labelY + 11;
|
||||||
if (trimmedRuntimeLabel) {
|
if (trimmedRuntimeLabel) {
|
||||||
ctx.font = '8px monospace';
|
ctx.font = '8px monospace';
|
||||||
ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
|
drawLabelText(ctx, truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY, '#b9d7f2', 10);
|
||||||
ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY);
|
|
||||||
nextLineY += 10;
|
nextLineY += 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedLaunchStatusLabel) {
|
if (trimmedLaunchStatusLabel) {
|
||||||
ctx.font = '7px monospace';
|
ctx.font = '7px monospace';
|
||||||
ctx.fillStyle = getLaunchStatusColor(launchVisualState);
|
drawLabelText(
|
||||||
ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY);
|
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 {
|
function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string {
|
||||||
const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2);
|
const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2);
|
||||||
if (ctx.measureText(label).width <= maxWidth) return label;
|
if (ctx.measureText(label).width <= maxWidth) return label;
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,7 @@ export class TeamGraphAdapter {
|
||||||
): void {
|
): void {
|
||||||
const percent = leadContext?.contextUsedPercent;
|
const percent = leadContext?.contextUsedPercent;
|
||||||
const leadMember = data.members.find((member) => member.name === leadName);
|
const leadMember = data.members.find((member) => member.name === leadName);
|
||||||
|
const isTeamVisualOnline = data.isAlive || isTeamProvisioning;
|
||||||
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
||||||
activeTools?.[leadName],
|
activeTools?.[leadName],
|
||||||
finishedVisible?.[leadName]
|
finishedVisible?.[leadName]
|
||||||
|
|
@ -437,7 +438,7 @@ export class TeamGraphAdapter {
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const leadState =
|
const leadState =
|
||||||
leadActivity === 'offline'
|
!isTeamVisualOnline || leadActivity === 'offline'
|
||||||
? 'terminated'
|
? 'terminated'
|
||||||
: leadActivity === 'idle'
|
: leadActivity === 'idle'
|
||||||
? 'idle'
|
? 'idle'
|
||||||
|
|
@ -445,7 +446,7 @@ export class TeamGraphAdapter {
|
||||||
? 'tool_calling'
|
? 'tool_calling'
|
||||||
: 'active';
|
: 'active';
|
||||||
const leadException =
|
const leadException =
|
||||||
leadActivity === 'offline'
|
!isTeamVisualOnline || leadActivity === 'offline'
|
||||||
? { exceptionTone: 'error' as const, exceptionLabel: 'offline' }
|
? { exceptionTone: 'error' as const, exceptionLabel: 'offline' }
|
||||||
: pendingApproval
|
: pendingApproval
|
||||||
? { exceptionTone: 'warning' as const, exceptionLabel: 'awaiting approval' }
|
? { exceptionTone: 'warning' as const, exceptionLabel: 'awaiting approval' }
|
||||||
|
|
@ -455,7 +456,7 @@ export class TeamGraphAdapter {
|
||||||
kind: 'lead',
|
kind: 'lead',
|
||||||
label: data.config.name || teamName,
|
label: data.config.name || teamName,
|
||||||
state: leadState,
|
state: leadState,
|
||||||
color: data.config.color ?? undefined,
|
color: isTeamVisualOnline ? (data.config.color ?? undefined) : undefined,
|
||||||
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
||||||
leadMember?.providerId,
|
leadMember?.providerId,
|
||||||
leadMember?.model,
|
leadMember?.model,
|
||||||
|
|
@ -516,6 +517,7 @@ export class TeamGraphAdapter {
|
||||||
if (member.removedAt) continue;
|
if (member.removedAt) continue;
|
||||||
if (isLeadMember(member)) continue;
|
if (isLeadMember(member)) continue;
|
||||||
|
|
||||||
|
const isTeamVisualOnline = data.isAlive || isTeamProvisioning;
|
||||||
const memberId =
|
const memberId =
|
||||||
memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member);
|
memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member);
|
||||||
const spawn = spawnStatuses?.[member.name];
|
const spawn = spawnStatuses?.[member.name];
|
||||||
|
|
@ -546,19 +548,25 @@ export class TeamGraphAdapter {
|
||||||
id: memberId,
|
id: memberId,
|
||||||
kind: 'member',
|
kind: 'member',
|
||||||
label: member.name,
|
label: member.name,
|
||||||
state: hasRunningTool
|
state: !isTeamVisualOnline
|
||||||
? 'tool_calling'
|
? 'terminated'
|
||||||
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn),
|
: hasRunningTool
|
||||||
color: member.color ?? undefined,
|
? 'tool_calling'
|
||||||
|
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn),
|
||||||
|
color: isTeamVisualOnline ? (member.color ?? undefined) : undefined,
|
||||||
role: member.role ?? undefined,
|
role: member.role ?? undefined,
|
||||||
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
||||||
member.providerId,
|
member.providerId,
|
||||||
member.model,
|
member.model,
|
||||||
member.effort
|
member.effort
|
||||||
),
|
),
|
||||||
spawnStatus: spawn?.status,
|
spawnStatus: isTeamVisualOnline ? spawn?.status : undefined,
|
||||||
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
launchVisualState: isTeamVisualOnline
|
||||||
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
|
? (launchPresentation.launchVisualState ?? undefined)
|
||||||
|
: undefined,
|
||||||
|
launchStatusLabel: isTeamVisualOnline
|
||||||
|
? (launchPresentation.launchStatusLabel ?? undefined)
|
||||||
|
: undefined,
|
||||||
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
|
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
|
||||||
currentTaskId: member.currentTaskId ?? undefined,
|
currentTaskId: member.currentTaskId ?? undefined,
|
||||||
currentTaskSubject: member.currentTaskId
|
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', () => {
|
it('treats literal lead approval sources as lead-node pending approvals', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
const graph = adapter.adapt(
|
const graph = adapter.adapt(
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,17 @@ interface FillTextCall {
|
||||||
text: string;
|
text: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
fillStyle: string;
|
||||||
|
globalAlpha: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockContext() {
|
function createMockContext() {
|
||||||
const fillTextCalls: FillTextCall[] = [];
|
const fillTextCalls: FillTextCall[] = [];
|
||||||
|
const strokeTextCalls: FillTextCall[] = [];
|
||||||
const roundRectCalls: Array<{ x: number; y: number; width: number; height: number }> = [];
|
const roundRectCalls: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||||||
const gradient = { addColorStop: vi.fn() };
|
const gradient = { addColorStop: vi.fn() };
|
||||||
|
let fillStyle = '';
|
||||||
|
let globalAlpha = 1;
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
save: vi.fn(),
|
save: vi.fn(),
|
||||||
|
|
@ -51,22 +56,35 @@ function createMockContext() {
|
||||||
createLinearGradient: vi.fn(() => gradient),
|
createLinearGradient: vi.fn(() => gradient),
|
||||||
measureText: vi.fn((text: string) => ({ width: text.length * 4.5 })),
|
measureText: vi.fn((text: string) => ({ width: text.length * 4.5 })),
|
||||||
fillText: vi.fn((text: string, x: number, y: number) => {
|
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: '',
|
shadowColor: '',
|
||||||
shadowBlur: 0,
|
shadowBlur: 0,
|
||||||
shadowOffsetX: 0,
|
shadowOffsetX: 0,
|
||||||
shadowOffsetY: 0,
|
shadowOffsetY: 0,
|
||||||
fillStyle: '',
|
get fillStyle() {
|
||||||
|
return fillStyle;
|
||||||
|
},
|
||||||
|
set fillStyle(value: string) {
|
||||||
|
fillStyle = value;
|
||||||
|
},
|
||||||
strokeStyle: '',
|
strokeStyle: '',
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
font: '',
|
font: '',
|
||||||
textAlign: 'left' as CanvasTextAlign,
|
textAlign: 'left' as CanvasTextAlign,
|
||||||
textBaseline: 'alphabetic' as CanvasTextBaseline,
|
textBaseline: 'alphabetic' as CanvasTextBaseline,
|
||||||
globalAlpha: 1,
|
get globalAlpha() {
|
||||||
|
return globalAlpha;
|
||||||
|
},
|
||||||
|
set globalAlpha(value: number) {
|
||||||
|
globalAlpha = value;
|
||||||
|
},
|
||||||
} as unknown as CanvasRenderingContext2D;
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
|
||||||
return { ctx, fillTextCalls, roundRectCalls };
|
return { ctx, fillTextCalls, strokeTextCalls, roundRectCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('drawAgents', () => {
|
describe('drawAgents', () => {
|
||||||
|
|
@ -140,4 +158,61 @@ describe('drawAgents', () => {
|
||||||
expect(fillTextCalls.some((call) => call.text === 'waiting...')).toBe(false);
|
expect(fillTextCalls.some((call) => call.text === 'waiting...')).toBe(false);
|
||||||
expect(fillTextCalls.some((call) => call.text === 'connecting...')).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