feat(agent-graph): show live task log indicator

This commit is contained in:
777genius 2026-05-07 01:07:06 +03:00
parent 9b5b4023d2
commit 2c30fd2235
7 changed files with 220 additions and 5 deletions

View file

@ -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();
}

View file

@ -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 */

View file

@ -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,

View file

@ -118,7 +118,8 @@ export class TeamGraphAdapter {
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
layoutMode: GraphLayoutMode = 'radial',
gridOwnerOrder?: readonly string[]
gridOwnerOrder?: readonly string[],
activeTaskLogActivity?: Record<string, true>
): 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<string, unknown>,
memberNodeIdByAlias?: ReadonlyMap<string, string>,
leadId?: string,
leadName?: string
leadName?: string,
activeTaskLogActivity?: Record<string, true>
): void {
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
const taskDisplayIds = new Map<string, string>();
@ -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 },
});
}

View file

@ -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(() => {

View file

@ -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<string, true>
): 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(

View file

@ -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);
});
});