diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index e530b971..6ee99403 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -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; diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 8c19acca..a76d369c 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -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 diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 5fedb8f6..5540c24f 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -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( diff --git a/test/renderer/features/agent-graph/drawAgents.test.ts b/test/renderer/features/agent-graph/drawAgents.test.ts index 1208a2c6..12e5fd4f 100644 --- a/test/renderer/features/agent-graph/drawAgents.test.ts +++ b/test/renderer/features/agent-graph/drawAgents.test.ts @@ -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 + ); + }); });