feat(agent-graph): show live task log indicator
This commit is contained in:
parent
9b5b4023d2
commit
2c30fd2235
7 changed files with 220 additions and 5 deletions
|
|
@ -39,7 +39,7 @@ export function drawTasks(
|
||||||
ctx.globalAlpha = opacity;
|
ctx.globalAlpha = opacity;
|
||||||
|
|
||||||
if (simplify) {
|
if (simplify) {
|
||||||
drawTaskPillLod(ctx, x, y, node, isSelected, isHovered);
|
drawTaskPillLod(ctx, x, y, node, time, isSelected, isHovered);
|
||||||
} else {
|
} else {
|
||||||
drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
|
drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +145,10 @@ function drawTaskPill(
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.hasLiveTaskLogs) {
|
||||||
|
drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time);
|
||||||
|
}
|
||||||
|
|
||||||
// Subject (main title — large)
|
// Subject (main title — large)
|
||||||
if (node.sublabel) {
|
if (node.sublabel) {
|
||||||
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
|
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
|
||||||
|
|
@ -235,6 +239,7 @@ function drawTaskPillLod(
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
node: GraphNode,
|
node: GraphNode,
|
||||||
|
time: number,
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isHovered: boolean
|
isHovered: boolean
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -276,6 +281,45 @@ function drawTaskPillLod(
|
||||||
ctx.fill();
|
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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,8 @@ export interface GraphNode {
|
||||||
totalCommentCount?: number;
|
totalCommentCount?: number;
|
||||||
/** Unread comment count on this task */
|
/** Unread comment count on this task */
|
||||||
unreadCommentCount?: number;
|
unreadCommentCount?: number;
|
||||||
|
/** Recent live log activity is arriving for this task */
|
||||||
|
hasLiveTaskLogs?: boolean;
|
||||||
/** Synthetic overflow stack node instead of hidden task tails */
|
/** Synthetic overflow stack node instead of hidden task tails */
|
||||||
isOverflowStack?: boolean;
|
isOverflowStack?: boolean;
|
||||||
/** Number of hidden tasks behind this overflow stack */
|
/** Number of hidden tasks behind this overflow stack */
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ export function collapseOverflowStacksWithMeta(
|
||||||
? 'has_changes'
|
? 'has_changes'
|
||||||
: undefined,
|
: undefined,
|
||||||
isBlocked: hiddenTasks.some((task) => task.isBlocked),
|
isBlocked: hiddenTasks.some((task) => task.isBlocked),
|
||||||
|
hasLiveTaskLogs: hiddenTasks.some((task) => task.hasLiveTaskLogs) ? true : undefined,
|
||||||
isOverflowStack: true,
|
isOverflowStack: true,
|
||||||
overflowCount: hiddenTasks.length,
|
overflowCount: hiddenTasks.length,
|
||||||
overflowTaskIds,
|
overflowTaskIds,
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,8 @@ export class TeamGraphAdapter {
|
||||||
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
||||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
||||||
layoutMode: GraphLayoutMode = 'radial',
|
layoutMode: GraphLayoutMode = 'radial',
|
||||||
gridOwnerOrder?: readonly string[]
|
gridOwnerOrder?: readonly string[],
|
||||||
|
activeTaskLogActivity?: Record<string, true>
|
||||||
): GraphDataPort {
|
): GraphDataPort {
|
||||||
if (teamData?.teamName !== teamName) {
|
if (teamData?.teamName !== teamName) {
|
||||||
return TeamGraphAdapter.#emptyResult(teamName);
|
return TeamGraphAdapter.#emptyResult(teamName);
|
||||||
|
|
@ -203,7 +204,8 @@ export class TeamGraphAdapter {
|
||||||
commentReadState,
|
commentReadState,
|
||||||
memberNodeIdByAlias,
|
memberNodeIdByAlias,
|
||||||
leadId,
|
leadId,
|
||||||
leadName
|
leadName,
|
||||||
|
activeTaskLogActivity
|
||||||
);
|
);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||||
|
|
@ -627,7 +629,8 @@ export class TeamGraphAdapter {
|
||||||
commentReadState?: Record<string, unknown>,
|
commentReadState?: Record<string, unknown>,
|
||||||
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||||
leadId?: string,
|
leadId?: string,
|
||||||
leadName?: string
|
leadName?: string,
|
||||||
|
activeTaskLogActivity?: Record<string, true>
|
||||||
): void {
|
): void {
|
||||||
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
||||||
const taskDisplayIds = new Map<string, string>();
|
const taskDisplayIds = new Map<string, string>();
|
||||||
|
|
@ -698,6 +701,7 @@ export class TeamGraphAdapter {
|
||||||
blocksDisplayIds,
|
blocksDisplayIds,
|
||||||
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
||||||
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
||||||
|
hasLiveTaskLogs: activeTaskLogActivity?.[task.id] === true ? true : undefined,
|
||||||
domainRef: { kind: 'task', teamName, taskId: task.id },
|
domainRef: { kind: 'task', teamName, taskId: task.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export function useTeamGraphAdapter(
|
||||||
gridOwnerOrder,
|
gridOwnerOrder,
|
||||||
slotAssignments,
|
slotAssignments,
|
||||||
graphLayoutSession,
|
graphLayoutSession,
|
||||||
|
activeTaskLogActivity,
|
||||||
ensureTeamGraphSlotAssignments,
|
ensureTeamGraphSlotAssignments,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -92,6 +93,8 @@ export function useTeamGraphAdapter(
|
||||||
gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
|
gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
|
||||||
slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
||||||
graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
||||||
|
activeTaskLogActivity:
|
||||||
|
isActive && teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
|
||||||
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -189,7 +192,8 @@ export function useTeamGraphAdapter(
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
effectiveSlotAssignments,
|
effectiveSlotAssignments,
|
||||||
graphLayoutMode ?? 'radial',
|
graphLayoutMode ?? 'radial',
|
||||||
gridOwnerOrder
|
gridOwnerOrder,
|
||||||
|
activeTaskLogActivity
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
isActive,
|
isActive,
|
||||||
|
|
@ -208,6 +212,7 @@ export function useTeamGraphAdapter(
|
||||||
effectiveSlotAssignments,
|
effectiveSlotAssignments,
|
||||||
graphLayoutMode,
|
graphLayoutMode,
|
||||||
gridOwnerOrder,
|
gridOwnerOrder,
|
||||||
|
activeTaskLogActivity,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,31 @@ function findNode(graph: GraphDataPort, nodeId: string) {
|
||||||
return graph.nodes.find((node) => node.id === nodeId);
|
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', () => {
|
describe('TeamGraphAdapter particles', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
@ -1631,6 +1656,47 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
expect(findNode(readGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBeUndefined();
|
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', () => {
|
it('dedupes symmetric blocking links and ignores completed blockers for blocked state', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
const inProgressGraph = adapter.adapt(
|
const inProgressGraph = adapter.adapt(
|
||||||
|
|
|
||||||
93
test/renderer/features/agent-graph/drawTasks.test.ts
Normal file
93
test/renderer/features/agent-graph/drawTasks.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue