From 2c30fd223582776c69e51a2088a5326cde0a130a Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 01:07:06 +0300 Subject: [PATCH] feat(agent-graph): show live task log indicator --- packages/agent-graph/src/canvas/draw-tasks.ts | 46 ++++++++- packages/agent-graph/src/ports/types.ts | 2 + .../core/domain/collapseOverflowStacks.ts | 1 + .../renderer/adapters/TeamGraphAdapter.ts | 10 +- .../renderer/hooks/useTeamGraphAdapter.ts | 7 +- .../agent-graph/TeamGraphAdapter.test.ts | 66 +++++++++++++ .../features/agent-graph/drawTasks.test.ts | 93 +++++++++++++++++++ 7 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 test/renderer/features/agent-graph/drawTasks.test.ts diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index ccc691a8..fda78d88 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -39,7 +39,7 @@ export function drawTasks( ctx.globalAlpha = opacity; if (simplify) { - drawTaskPillLod(ctx, x, y, node, isSelected, isHovered); + drawTaskPillLod(ctx, x, y, node, time, isSelected, isHovered); } else { drawTaskPill(ctx, x, y, node, time, isSelected, isHovered); } @@ -145,6 +145,10 @@ function drawTaskPill( ctx.stroke(); } + if (node.hasLiveTaskLogs) { + drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time); + } + // Subject (main title — large) if (node.sublabel) { ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`; @@ -235,6 +239,7 @@ function drawTaskPillLod( x: number, y: number, node: GraphNode, + time: number, isSelected: boolean, isHovered: boolean ): void { @@ -276,6 +281,45 @@ function drawTaskPillLod( ctx.fill(); } + if (node.hasLiveTaskLogs) { + drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time, true); + } + + ctx.restore(); +} + +function drawLiveTaskLogIndicator( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + time: number, + compact = false +): void { + const coreRadius = compact ? 2.5 : 3.4; + const glowRadius = compact ? 7 : 10; + const pulse = 0.55 + 0.25 * Math.sin(time * 6); + const color = COLORS.reviewApproved; + + const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius); + glow.addColorStop(0, hexWithAlpha(color, 0.35 + pulse * 0.28)); + glow.addColorStop(1, hexWithAlpha(color, 0)); + + ctx.save(); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(x, y, glowRadius, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = hexWithAlpha(color, 0.95); + ctx.beginPath(); + ctx.arc(x, y, coreRadius, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = hexWithAlpha(color, pulse); + ctx.lineWidth = compact ? 0.8 : 1; + ctx.beginPath(); + ctx.arc(x, y, coreRadius + (compact ? 1.2 : 1.8), 0, Math.PI * 2); + ctx.stroke(); ctx.restore(); } diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 8a990dbb..b98db6fa 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -164,6 +164,8 @@ export interface GraphNode { totalCommentCount?: number; /** Unread comment count on this task */ unreadCommentCount?: number; + /** Recent live log activity is arriving for this task */ + hasLiveTaskLogs?: boolean; /** Synthetic overflow stack node instead of hidden task tails */ isOverflowStack?: boolean; /** Number of hidden tasks behind this overflow stack */ diff --git a/src/features/agent-graph/core/domain/collapseOverflowStacks.ts b/src/features/agent-graph/core/domain/collapseOverflowStacks.ts index 1ef45c8f..8d99dbc1 100644 --- a/src/features/agent-graph/core/domain/collapseOverflowStacks.ts +++ b/src/features/agent-graph/core/domain/collapseOverflowStacks.ts @@ -107,6 +107,7 @@ export function collapseOverflowStacksWithMeta( ? 'has_changes' : undefined, isBlocked: hiddenTasks.some((task) => task.isBlocked), + hasLiveTaskLogs: hiddenTasks.some((task) => task.hasLiveTaskLogs) ? true : undefined, isOverflowStack: true, overflowCount: hiddenTasks.length, overflowTaskIds, diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 8dbf3086..56da2f01 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -118,7 +118,8 @@ export class TeamGraphAdapter { memberSpawnSnapshot?: MemberSpawnStatusesSnapshot, slotAssignments?: Record, layoutMode: GraphLayoutMode = 'radial', - gridOwnerOrder?: readonly string[] + gridOwnerOrder?: readonly string[], + activeTaskLogActivity?: Record ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); @@ -203,7 +204,8 @@ export class TeamGraphAdapter { commentReadState, memberNodeIdByAlias, leadId, - leadName + leadName, + activeTaskLogActivity ); this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); @@ -627,7 +629,8 @@ export class TeamGraphAdapter { commentReadState?: Record, memberNodeIdByAlias?: ReadonlyMap, leadId?: string, - leadName?: string + leadName?: string, + activeTaskLogActivity?: Record ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -698,6 +701,7 @@ export class TeamGraphAdapter { blocksDisplayIds, totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined, unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined, + hasLiveTaskLogs: activeTaskLogActivity?.[task.id] === true ? true : undefined, domainRef: { kind: 'task', teamName, taskId: task.id }, }); } diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index f3ddcb70..9b9feca3 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -71,6 +71,7 @@ export function useTeamGraphAdapter( gridOwnerOrder, slotAssignments, graphLayoutSession, + activeTaskLogActivity, ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ @@ -92,6 +93,8 @@ export function useTeamGraphAdapter( gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined, slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined, graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined, + activeTaskLogActivity: + isActive && teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined, ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments, })) ); @@ -189,7 +192,8 @@ export function useTeamGraphAdapter( memberSpawnSnapshot, effectiveSlotAssignments, graphLayoutMode ?? 'radial', - gridOwnerOrder + gridOwnerOrder, + activeTaskLogActivity ); }, [ isActive, @@ -208,6 +212,7 @@ export function useTeamGraphAdapter( effectiveSlotAssignments, graphLayoutMode, gridOwnerOrder, + activeTaskLogActivity, ]); useLayoutEffect(() => { diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 5540c24f..c2cdc1ed 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -62,6 +62,31 @@ function findNode(graph: GraphDataPort, nodeId: string) { return graph.nodes.find((node) => node.id === nodeId); } +function adaptWithActiveTaskLogActivity( + adapter: TeamGraphAdapter, + teamData: TeamGraphData, + activeTaskLogActivity: Record +): GraphDataPort { + return adapter.adapt( + teamData, + 'my-team', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + activeTaskLogActivity + ); +} + describe('TeamGraphAdapter particles', () => { beforeEach(() => { vi.useFakeTimers(); @@ -1631,6 +1656,47 @@ describe('TeamGraphAdapter particles', () => { expect(findNode(readGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBeUndefined(); }); + it('projects live task log activity onto visible task nodes and overflow stacks', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adaptWithActiveTaskLogActivity( + adapter, + createBaseTeamData({ + tasks: [ + { + id: 'task-live-visible', + displayId: '#1', + subject: 'Visible live logs', + owner: 'alice', + status: 'in_progress', + reviewState: 'none', + }, + ...Array.from({ length: 5 }, (_, index) => ({ + id: `task-overflow-${index + 1}`, + displayId: `#${index + 2}`, + subject: `Overflow task ${index + 1}`, + owner: 'alice', + status: 'in_progress', + reviewState: 'none', + })), + ] as TeamTaskWithKanban[], + }), + { + 'task-live-visible': true, + 'task-overflow-5': true, + } + ); + + const visibleLiveTask = findNode(graph, 'task:my-team:task-live-visible'); + const overflowNode = graph.nodes.find((node) => node.kind === 'task' && node.isOverflowStack); + + expect(visibleLiveTask).toMatchObject({ hasLiveTaskLogs: true }); + expect(overflowNode).toMatchObject({ + hasLiveTaskLogs: true, + overflowTaskIds: expect.arrayContaining(['task-overflow-5']), + }); + expect(findNode(graph, 'task:my-team:task-overflow-1')?.hasLiveTaskLogs).toBeUndefined(); + }); + it('dedupes symmetric blocking links and ignores completed blockers for blocked state', () => { const adapter = TeamGraphAdapter.create(); const inProgressGraph = adapter.adapt( diff --git a/test/renderer/features/agent-graph/drawTasks.test.ts b/test/renderer/features/agent-graph/drawTasks.test.ts new file mode 100644 index 00000000..de481ffe --- /dev/null +++ b/test/renderer/features/agent-graph/drawTasks.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { drawTasks } from '../../../../packages/agent-graph/src/canvas/draw-tasks'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +function createMockContext() { + const arcCalls: Array<{ x: number; y: number; radius: number }> = []; + const gradient = { addColorStop: vi.fn() }; + let fillStyle: string | CanvasGradient | CanvasPattern = ''; + let globalAlpha = 1; + + const ctx = { + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + closePath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn((x: number, y: number, radius: number) => { + arcCalls.push({ x, y, radius }); + }), + fill: vi.fn(), + stroke: vi.fn(), + clip: vi.fn(), + drawImage: vi.fn(), + setLineDash: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + roundRect: vi.fn(), + createRadialGradient: vi.fn(() => gradient), + createLinearGradient: vi.fn(() => gradient), + measureText: vi.fn((text: string) => ({ width: text.length * 4.5 })), + fillText: vi.fn(), + strokeText: vi.fn(), + shadowColor: '', + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + strokeStyle: '', + lineWidth: 1, + font: '', + textAlign: 'left' as CanvasTextAlign, + textBaseline: 'alphabetic' as CanvasTextBaseline, + get fillStyle() { + return fillStyle; + }, + set fillStyle(value: string | CanvasGradient | CanvasPattern) { + fillStyle = value; + }, + get globalAlpha() { + return globalAlpha; + }, + set globalAlpha(value: number) { + globalAlpha = value; + }, + } as unknown as CanvasRenderingContext2D; + + return { ctx, arcCalls }; +} + +function createTaskNode(hasLiveTaskLogs: boolean): GraphNode { + return { + id: 'task:demo:task-live', + kind: 'task', + label: '#1', + state: 'active', + displayId: '#1', + sublabel: 'Live log task', + taskStatus: 'in_progress', + reviewState: 'none', + hasLiveTaskLogs: hasLiveTaskLogs ? true : undefined, + domainRef: { kind: 'task', teamName: 'demo', taskId: 'task-live' }, + x: 120, + y: 80, + }; +} + +describe('drawTasks', () => { + it('draws the live log indicator only for task nodes with live log activity', () => { + const active = createMockContext(); + drawTasks(active.ctx, [createTaskNode(true)], 1, null, null, null, 1); + + const inactive = createMockContext(); + drawTasks(inactive.ctx, [createTaskNode(false)], 1, null, null, null, 1); + + expect(active.arcCalls.length).toBeGreaterThanOrEqual(3); + expect(inactive.arcCalls).toHaveLength(0); + }); +});