agent-ecosystem/packages/agent-graph/src/canvas/draw-tasks.ts

510 lines
15 KiB
TypeScript

/**
* Task pill-shaped node rendering.
* NEW — not from agent-flow. Custom renderer for our task nodes.
*/
import type { GraphNode } from '../ports/types';
import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors';
import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants';
import { truncateText, wrapTextLines } from './draw-misc';
import { drawPillShell, drawPillStackLayer } from './draw-pill-shell';
import { hexWithAlpha } from './render-cache';
import type { KanbanZoneInfo } from '../layout/kanbanLayout';
const KANBAN_HEADER_FONT = '600 10px monospace';
const KANBAN_HEADER_ALPHA = 0.92;
const KANBAN_HEADER_LETTER_SPACING = 2;
/**
* Draw all task nodes as pill-shaped cards.
*/
export function drawTasks(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
time: number,
selectedId: string | null,
hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | null,
zoom = 1
): void {
const simplify = zoom < 0.2;
for (const node of nodes) {
if (node.kind !== 'task') continue;
const opacity = getTaskOpacity(node, focusNodeIds);
if (opacity < MIN_VISIBLE_OPACITY) continue;
const x = node.x ?? 0;
const y = node.y ?? 0;
const isSelected = node.id === selectedId;
const isHovered = node.id === hoveredId;
ctx.save();
ctx.globalAlpha = opacity;
if (simplify) {
drawTaskPillLod(ctx, x, y, node, time, isSelected, isHovered);
} else {
drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
}
ctx.restore();
}
}
// ─── Private ────────────────────────────────────────────────────────────────
function getTaskOpacity(node: GraphNode, focusNodeIds?: ReadonlySet<string> | null): number {
if (node.taskStatus === 'deleted') return 0;
if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25;
return 1;
}
function drawTaskPill(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
node: GraphNode,
time: number,
isSelected: boolean,
isHovered: boolean
): void {
const w = TASK_PILL.width;
const h = TASK_PILL.height;
const r = TASK_PILL.borderRadius;
const halfW = w / 2;
const halfH = h / 2;
const statusColor = getTaskStatusColor(node.taskStatus);
const reviewColor = getReviewStateColor(node.reviewState);
ctx.save();
ctx.translate(x, y);
if (node.isOverflowStack) {
drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered);
ctx.restore();
return;
}
// Pulse only for active work — completed + approved = static
const needsAttention =
(node.taskStatus === 'in_progress' && node.reviewState !== 'approved') ||
node.reviewState === 'review' ||
node.reviewState === 'needsFix' ||
node.needsClarification != null;
const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved';
const breathe =
needsAttention && !isFinished
? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed)
: 1;
const scale = breathe;
ctx.scale(scale, scale);
// Shadow — stronger for attention tasks, red for blocked
ctx.shadowColor = node.isBlocked
? hexWithAlpha(COLORS.edgeBlocking, 0.3)
: hexWithAlpha(statusColor, 0.25);
ctx.shadowBlur = needsAttention || node.isBlocked ? 12 : 4;
// Background fill
drawPillShell(ctx, {
width: w,
height: h,
radius: r,
fillStyle: isSelected
? COLORS.cardBgSelected
: isHovered
? 'rgba(15, 20, 40, 0.7)'
: COLORS.cardBg,
borderColor: node.isBlocked
? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.9 : 0.7)
: hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5),
borderWidth: node.isBlocked ? (isSelected ? 2.5 : 1.8) : isSelected ? 2 : 1,
shadowColor: node.isBlocked
? hexWithAlpha(COLORS.edgeBlocking, 0.3)
: hexWithAlpha(statusColor, 0.25),
shadowBlur: needsAttention || node.isBlocked ? 12 : 4,
accentColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.6) : undefined,
});
// Review state overlay border — pulsing for review/needsFix, STATIC for approved
if (reviewColor !== 'transparent') {
ctx.beginPath();
ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1);
const reviewAlpha = node.reviewState === 'approved' ? 0.6 : 0.5 + 0.3 * Math.sin(time * 3);
ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Clarification warning indicator
if (node.needsClarification) {
const pulseAlpha = 0.4 + 0.4 * Math.sin(time * 4);
ctx.beginPath();
ctx.roundRect(-halfW - 2, -halfH - 2, w + 4, h + 4, r + 2);
ctx.strokeStyle = hexWithAlpha(COLORS.error, pulseAlpha);
ctx.lineWidth = 1;
ctx.stroke();
}
if (node.hasLiveTaskLogs) {
drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time);
}
// Subject (main title - up to two lines)
let subjectLineCount = 0;
if (node.sublabel) {
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary;
const textX = -halfW + 10;
const hasReviewChip =
node.reviewState !== 'approved' &&
(node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName));
const maxW = hasReviewChip ? w - 88 : w - 24;
const subjectLines = wrapTextLines(ctx, node.sublabel, maxW, ctx.font, 2);
subjectLineCount = subjectLines.length;
const titleStartY = subjectLines.length > 1 ? -16 : -12;
const titleLineHeight = TASK_PILL.idFontSize + 1.5;
subjectLines.forEach((line, index) => {
ctx.fillText(line, textX, titleStartY + index * titleLineHeight);
});
}
// Display ID (secondary — small)
const displayId = node.displayId ?? node.label;
ctx.font = `${TASK_PILL.subjectFontSize}px monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textDim;
ctx.fillText(displayId, -halfW + 10, subjectLineCount > 1 ? 23 : 12);
// Approved badge: checkmark at right side
if (node.reviewState === 'approved') {
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.reviewApproved;
ctx.fillText('\u2713', halfW - 8, 0); // ✓
}
if (
node.reviewState !== 'approved' &&
(node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && node.reviewerName))
) {
drawReviewChip(ctx, halfW, -halfH, node);
}
// Comment count badge — on the bottom-right border edge, 1.5x bigger
if (node.totalCommentCount && node.totalCommentCount > 0) {
const badgeX = halfW - 6;
const badgeY = halfH;
// Speech bubble background
const bw = 20;
const bh = 15;
ctx.fillStyle = hexWithAlpha('#aaeeff', 0.85);
ctx.beginPath();
ctx.roundRect(badgeX - bw / 2, badgeY - bh / 2, bw, bh, 3);
ctx.fill();
// Tail pointing up-left
ctx.beginPath();
ctx.moveTo(badgeX - 5, badgeY + bh / 2);
ctx.lineTo(badgeX - 9, badgeY + bh / 2 + 5);
ctx.lineTo(badgeX - 1, badgeY + bh / 2);
ctx.closePath();
ctx.fill();
// Total count inside bubble
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#0a0f1e';
ctx.fillText(String(node.totalCommentCount), badgeX, badgeY + 0.5);
// Unread count badge (blue circle, top-right of bubble)
if (node.unreadCommentCount && node.unreadCommentCount > 0) {
const dotX = badgeX + bw / 2 + 1;
const dotY = badgeY - bh / 2 - 1;
ctx.fillStyle = '#3b82f6';
ctx.beginPath();
ctx.arc(dotX, dotY, 7, 0, Math.PI * 2);
ctx.fill();
ctx.font = 'bold 8px monospace';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(node.unreadCommentCount), dotX, dotY + 0.5);
}
}
ctx.restore();
}
function drawTaskPillLod(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
node: GraphNode,
time: number,
isSelected: boolean,
isHovered: boolean
): void {
const w = TASK_PILL.width;
const h = TASK_PILL.height;
const r = TASK_PILL.borderRadius;
const halfW = w / 2;
const halfH = h / 2;
const statusColor = getTaskStatusColor(node.taskStatus);
ctx.save();
ctx.translate(x, y);
if (node.isOverflowStack) {
drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered);
ctx.restore();
return;
}
ctx.beginPath();
ctx.roundRect(-halfW, -halfH, w, h, r);
ctx.fillStyle = isSelected
? COLORS.cardBgSelected
: isHovered
? 'rgba(15, 20, 40, 0.78)'
: COLORS.cardBg;
ctx.fill();
ctx.strokeStyle = node.isBlocked
? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65)
: hexWithAlpha(statusColor, isSelected ? 0.8 : 0.55);
ctx.lineWidth = node.isBlocked ? (isSelected ? 2.2 : 1.5) : isSelected ? 2 : 1;
ctx.stroke();
if (node.isBlocked) {
ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6);
ctx.beginPath();
ctx.roundRect(-halfW, -halfH, 4, h, [r, 0, 0, r]);
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();
}
function drawOverflowStack(
ctx: CanvasRenderingContext2D,
halfW: number,
halfH: number,
r: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean
): void {
for (const [offset, alpha] of [
[6, 0.18],
[3, 0.28],
] as const) {
drawPillStackLayer(ctx, {
width: TASK_PILL.width,
height: TASK_PILL.height,
radius: r,
offsetX: offset,
offsetY: -offset,
fillColor: '#334155',
fillAlpha: alpha,
});
}
drawPillShell(ctx, {
width: TASK_PILL.width,
height: TASK_PILL.height,
radius: r,
fillStyle: isSelected
? COLORS.cardBgSelected
: isHovered
? 'rgba(15, 20, 40, 0.78)'
: COLORS.cardBg,
borderColor: node.isBlocked
? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65)
: hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55),
borderWidth: node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 2 : 1,
accentColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.6) : undefined,
});
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary;
ctx.fillText(node.label, -halfW + 14, -8);
ctx.font = '10px monospace';
ctx.fillStyle = COLORS.textDim;
ctx.fillText('more tasks', -halfW + 14, 12);
}
function drawReviewChip(
ctx: CanvasRenderingContext2D,
halfW: number,
halfH: number,
node: GraphNode
): void {
const chipText = node.reviewMode === 'manual' ? 'REV' : (node.reviewerName ?? 'REV');
const chipColor = node.reviewMode === 'manual' ? '#8b5cf6' : (node.reviewerColor ?? '#38bdf8');
const chipX = halfW - 44;
const chipY = halfH + 10;
const chipW = 34;
const chipH = 12;
ctx.beginPath();
ctx.roundRect(chipX, chipY, chipW, chipH, 6);
ctx.fillStyle = hexWithAlpha(chipColor, 0.2);
ctx.fill();
ctx.strokeStyle = hexWithAlpha(chipColor, 0.55);
ctx.lineWidth = 1;
ctx.stroke();
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(chipColor, 0.95);
ctx.fillText(
chipText.length > 8 ? `${chipText.slice(0, 7)}` : chipText,
chipX + chipW / 2,
chipY + chipH / 2 + 0.5
);
if (node.changePresence === 'has_changes') {
ctx.beginPath();
ctx.arc(chipX + chipW + 4, chipY + chipH / 2, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#38bdf8';
ctx.fill();
}
}
function measureSpacedText(
ctx: CanvasRenderingContext2D,
text: string,
letterSpacing: number
): number {
const chars = Array.from(text);
const glyphWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);
return glyphWidth + Math.max(0, chars.length - 1) * letterSpacing;
}
function drawCenteredSpacedText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
letterSpacing: number
): void {
const chars = Array.from(text);
const previousAlign = ctx.textAlign;
ctx.textAlign = 'left';
let cursorX = x - measureSpacedText(ctx, text, letterSpacing) / 2;
for (const char of chars) {
ctx.fillText(char, cursorX, y);
cursorX += ctx.measureText(char).width + letterSpacing;
}
ctx.textAlign = previousAlign;
}
/**
* Draw kanban column headers above task columns.
*/
export function drawColumnHeaders(
ctx: CanvasRenderingContext2D,
zones: KanbanZoneInfo[],
zoom = 1
): void {
if (zoom < 0.22) return;
for (const zone of zones) {
// Section header for unassigned tasks — larger, centered above all columns
if (zone.ownerId === '__unassigned__') {
ctx.font = KANBAN_HEADER_FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(COLORS.taskPending, KANBAN_HEADER_ALPHA);
const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) + 10;
drawCenteredSpacedText(ctx, 'Unassigned', zone.ownerX, labelY, KANBAN_HEADER_LETTER_SPACING);
// Overflow badge
for (const header of zone.headers) {
if (header.overflowCount > 0) {
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = hexWithAlpha(header.color, 0.45);
ctx.fillText(`+${header.overflowCount} more`, header.x, header.overflowY + 4);
}
}
continue;
}
for (const header of zone.headers) {
ctx.font = KANBAN_HEADER_FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(header.color, KANBAN_HEADER_ALPHA);
drawCenteredSpacedText(
ctx,
header.label,
header.x,
header.y + 10,
KANBAN_HEADER_LETTER_SPACING
);
// Overflow badge: "+N more"
if (header.overflowCount > 0) {
const badgeText = `+${header.overflowCount} more`;
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = hexWithAlpha(header.color, 0.45);
ctx.fillText(badgeText, header.x, header.overflowY + 4);
}
}
}
}