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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
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