feat(graph): enhance task rendering and interaction features

- Updated task opacity logic to simplify conditions.
- Added comment count and unread count badges to task pills for better visibility.
- Improved layout for unassigned tasks, including a section header and overflow badge.
- Enhanced task interaction by restricting drag functionality to member and lead nodes only.
- Introduced new task action event listeners for better task management in the UI.
- Preserved known task change presence across refreshes to maintain state consistency.
This commit is contained in:
iliya 2026-03-31 01:29:59 +03:00
parent 8fe1487708
commit 16f069fae3
14 changed files with 626 additions and 152 deletions

View file

@ -42,10 +42,8 @@ export function drawTasks(
// ─── Private ────────────────────────────────────────────────────────────────
function getTaskOpacity(node: GraphNode): number {
if (node.taskStatus === 'deleted') return 0;
if (node.reviewState === 'approved') return 0.65;
if (node.taskStatus === 'completed') return 0.45;
function getTaskOpacity(_node: GraphNode): number {
if (_node.taskStatus === 'deleted') return 0;
return 1;
}
@ -142,27 +140,16 @@ function drawTaskPill(
ctx.stroke();
}
// Status dot
ctx.fillStyle = statusColor;
ctx.beginPath();
ctx.arc(
-halfW + TASK_PILL.statusDotX,
0,
TASK_PILL.statusDotRadius,
0,
Math.PI * 2,
);
ctx.fill();
// Subject (main title — large)
if (node.sublabel) {
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = isFinished ? COLORS.textDim : COLORS.textPrimary;
const maxW = w - TASK_PILL.textOffsetX - 8;
ctx.fillStyle = COLORS.textPrimary;
const textX = -halfW + 10;
const maxW = w - 18;
const subject = truncateText(ctx, node.sublabel, maxW, ctx.font);
ctx.fillText(subject, -halfW + TASK_PILL.textOffsetX, -4);
ctx.fillText(subject, textX, -4);
}
// Display ID (secondary — small)
@ -170,8 +157,8 @@ function drawTaskPill(
ctx.font = `${TASK_PILL.subjectFontSize}px monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = isFinished ? COLORS.textMuted : COLORS.textDim;
ctx.fillText(displayId, -halfW + TASK_PILL.textOffsetX, 8);
ctx.fillStyle = COLORS.textDim;
ctx.fillText(displayId, -halfW + 10, 8);
// Approved badge: checkmark at right side
if (node.reviewState === 'approved') {
@ -182,14 +169,47 @@ function drawTaskPill(
ctx.fillText('\u2713', halfW - 8, 0); // ✓
}
// Completed: subtle strikethrough line
if (node.taskStatus === 'completed' && node.reviewState !== 'approved') {
// 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.moveTo(-halfW + TASK_PILL.textOffsetX, 0);
ctx.lineTo(halfW - 10, 0);
ctx.strokeStyle = COLORS.textMuted;
ctx.lineWidth = 0.5;
ctx.stroke();
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();
@ -203,6 +223,28 @@ export function drawColumnHeaders(
zones: KanbanZoneInfo[],
): void {
for (const zone of zones) {
// Section header for unassigned tasks — larger, centered above all columns
if (zone.ownerId === '__unassigned__') {
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillStyle = hexWithAlpha(COLORS.taskPending, 0.5);
const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) - 16;
ctx.fillText('Unassigned', zone.ownerX, labelY);
// 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 = 'bold 8px monospace';
ctx.textAlign = 'center';
@ -210,15 +252,6 @@ export function drawColumnHeaders(
ctx.fillStyle = hexWithAlpha(header.color, 0.6);
ctx.fillText(header.label, header.x, header.y - 2);
// Subtle underline
const labelWidth = ctx.measureText(header.label).width;
ctx.beginPath();
ctx.moveTo(header.x - labelWidth / 2, header.y);
ctx.lineTo(header.x + labelWidth / 2, header.y);
ctx.strokeStyle = hexWithAlpha(header.color, 0.2);
ctx.lineWidth = 0.5;
ctx.stroke();
// Overflow badge: "+N more"
if (header.overflowCount > 0) {
const badgeText = `+${header.overflowCount} more`;

View file

@ -33,7 +33,11 @@ export function useGraphInteraction(
clickedNodeId.current = hit;
if (hit) {
dragNodeId.current = hit;
// Only allow drag on member/lead nodes, not tasks or processes
const hitNode = nodes.find((n) => n.id === hit);
if (hitNode && (hitNode.kind === 'member' || hitNode.kind === 'lead')) {
dragNodeId.current = hit;
}
}
}, []);

View file

@ -88,7 +88,7 @@ export class KanbanLayoutEngine {
if (zoneInfo) this.zones.push(zoneInfo);
}
KanbanLayoutEngine.#layoutUnassigned(unassigned);
KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes);
}
// ─── Private ──────────────────────────────────────────────────────────────
@ -178,11 +178,55 @@ export class KanbanLayoutEngine {
}
}
static #layoutUnassigned(tasks: GraphNode[]): void {
static #layoutUnassigned(tasks: GraphNode[], allNodes: GraphNode[]): void {
if (tasks.length === 0) return;
const { columnWidth, rowHeight } = KANBAN_ZONE;
// Find the lowest Y of ALL positioned nodes (members + their owned tasks)
let sumX = 0;
let maxY = -Infinity;
let memberCount = 0;
for (const n of allNodes) {
if (n.x == null || n.y == null) continue;
// Skip unassigned tasks themselves (they have no ownerId)
if (n.kind === 'task' && !n.ownerId) continue;
if (n.y > maxY) maxY = n.y;
if (n.kind !== 'task') {
sumX += n.x;
memberCount++;
}
}
const centerX = memberCount > 0 ? sumX / memberCount : 0;
// Place unassigned tasks well below the lowest element
const baseY = (maxY > -Infinity ? maxY : 0) + 150;
const cols = Math.min(tasks.length, 4);
const totalWidth = cols * columnWidth;
const baseX = centerX - totalWidth / 2;
// Add zone header for unassigned section
if (tasks.length > 0) {
this.zones.push({
ownerId: '__unassigned__',
ownerX: centerX,
ownerY: baseY - 70,
headers: [{
label: 'Unassigned',
x: centerX,
y: baseY - 10,
color: COLORS.taskPending,
overflowCount: Math.max(0, tasks.length - cols * KANBAN_ZONE.maxVisibleRows),
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
}],
});
}
for (const [idx, task] of tasks.entries()) {
const targetX = -400 + (idx % 3) * columnWidth;
const targetY = 400 + Math.floor(idx / 3) * rowHeight;
const col = idx % cols;
const row = Math.floor(idx / cols);
const targetX = baseX + col * columnWidth;
const targetY = baseY + row * rowHeight;
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
task.fx = task.x;

View file

@ -96,6 +96,10 @@ export interface GraphNode {
blockedByDisplayIds?: string[];
/** Display IDs of tasks this one blocks */
blocksDisplayIds?: string[];
/** Total comment count on this task */
totalCommentCount?: number;
/** Unread comment count on this task */
unreadCommentCount?: number;
// ─── Process-specific ──────────────────────────────────────────────────
/** Clickable URL for process */

View file

@ -248,8 +248,9 @@ export function GraphView({
// Check if we hit a node
interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes);
if (interaction.dragNodeId.current) {
// Hit a node → will drag it
// Hit a node (draggable or clickable) → don't pan
const hitNode = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes);
if (hitNode) {
markUserInteracted();
isPanningRef.current = false;
} else {

View file

@ -268,11 +268,107 @@ export const TeamDetailView = ({
window.addEventListener('graph:send-message', onSendMsg);
window.addEventListener('graph:open-profile', onOpenProfile);
window.addEventListener('graph:create-task', onCreateTask);
// Task action events from graph
const taskAction = (handler: (taskId: string) => void) => (e: Event) => {
const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {};
if (tn !== teamName || !taskId) return;
handler(taskId);
};
const onStartTask = taskAction((taskId) => {
void (async () => {
try {
const result = await startTaskByUser(teamName, taskId);
if (data?.isAlive) {
const task = data.tasks.find((t: { id: string }) => t.id === taskId);
try {
if (result.notifiedOwner && task?.owner) {
await api.teams.processSend(
teamName,
`Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.`
);
}
} catch {
/* best-effort */
}
}
} catch {
/* error via store */
}
})();
});
const onCompleteTask = taskAction((taskId) => {
void (async () => {
try {
await updateTaskStatus(teamName, taskId, 'completed');
} catch {
/* */
}
})();
});
const onApproveTask = taskAction((taskId) => {
void (async () => {
try {
await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' });
} catch {
/* */
}
})();
});
const onRequestReviewTask = taskAction((taskId) => {
void (async () => {
try {
await requestReview(teamName, taskId);
} catch {
/* */
}
})();
});
const onRequestChangesTask = taskAction((taskId) => {
setRequestChangesTaskId(taskId);
});
const onCancelTask = taskAction((taskId) => {
void (async () => {
try {
await updateTaskStatus(teamName, taskId, 'pending');
} catch {
/* */
}
})();
});
const onMoveBackToDoneTask = taskAction((taskId) => {
void (async () => {
try {
await updateKanban(teamName, taskId, { op: 'remove' });
await updateTaskStatus(teamName, taskId, 'completed');
} catch {
/* */
}
})();
});
const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId));
window.addEventListener('graph:start-task', onStartTask);
window.addEventListener('graph:complete-task', onCompleteTask);
window.addEventListener('graph:approve-task', onApproveTask);
window.addEventListener('graph:request-review', onRequestReviewTask);
window.addEventListener('graph:request-changes', onRequestChangesTask);
window.addEventListener('graph:cancel-task', onCancelTask);
window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask);
window.addEventListener('graph:delete-task', onDeleteTaskGraph);
return () => {
window.removeEventListener('graph:open-task', onOpenTask);
window.removeEventListener('graph:send-message', onSendMsg);
window.removeEventListener('graph:open-profile', onOpenProfile);
window.removeEventListener('graph:create-task', onCreateTask);
window.removeEventListener('graph:start-task', onStartTask);
window.removeEventListener('graph:complete-task', onCompleteTask);
window.removeEventListener('graph:approve-task', onApproveTask);
window.removeEventListener('graph:request-review', onRequestReviewTask);
window.removeEventListener('graph:request-changes', onRequestChangesTask);
window.removeEventListener('graph:cancel-task', onCancelTask);
window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask);
window.removeEventListener('graph:delete-task', onDeleteTaskGraph);
};
});

View file

@ -7,6 +7,7 @@
* Class-based with ES #private fields, caching, and DI-ready constructor.
*/
import { getUnreadCount } from '@renderer/services/commentReadStorage';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { getInboxJsonType, isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection';
@ -60,7 +61,8 @@ export class TeamGraphAdapter {
pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
toolHistory?: Record<string, ActiveToolCall[]>
toolHistory?: Record<string, ActiveToolCall[]>,
commentReadState?: Record<string, unknown>
): GraphDataPort {
if (teamData?.teamName !== teamName) {
return TeamGraphAdapter.#emptyResult(teamName);
@ -144,7 +146,7 @@ export class TeamGraphAdapter {
.sort()
.join('|')
: '';
const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}`;
const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}:${commentReadState ? Object.keys(commentReadState).length : 0}`;
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
return this.#cachedResult;
}
@ -191,7 +193,7 @@ export class TeamGraphAdapter {
finishedVisible,
toolHistory
);
this.#buildTaskNodes(nodes, edges, teamData, teamName);
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
this.#buildProcessNodes(nodes, edges, teamData, teamName);
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, leadName, edges);
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
@ -369,7 +371,13 @@ export class TeamGraphAdapter {
}
}
#buildTaskNodes(nodes: GraphNode[], edges: GraphEdge[], data: TeamData, teamName: string): void {
#buildTaskNodes(
nodes: GraphNode[],
edges: GraphEdge[],
data: TeamData,
teamName: string,
commentReadState?: Record<string, unknown>
): void {
// Build lookup tables for fast resolution
const completedTaskIds = new Set<string>();
const taskDisplayIds = new Map<string, string>();
@ -396,6 +404,17 @@ export class TeamGraphAdapter {
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
: undefined;
// Comment counts
const totalCommentCount = task.comments?.length ?? 0;
const unreadCommentCount = commentReadState
? getUnreadCount(
commentReadState as Parameters<typeof getUnreadCount>[0],
teamName,
task.id,
task.comments ?? []
)
: 0;
nodes.push({
id: taskId,
kind: 'task',
@ -410,6 +429,8 @@ export class TeamGraphAdapter {
isBlocked,
blockedByDisplayIds,
blocksDisplayIds,
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
domainRef: { kind: 'task', teamName, taskId: task.id },
});

View file

@ -3,8 +3,9 @@
* Thin wrapper instantiates the class adapter and calls adapt() with store data.
*/
import { useMemo, useRef } from 'react';
import { useMemo, useRef, useSyncExternalStore } from 'react';
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
@ -43,6 +44,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
return agents;
}, [pendingApprovals]);
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
return useMemo(
() =>
adapterRef.current.adapt(
@ -53,7 +56,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
pendingApprovalAgents,
activeTools,
finishedVisible,
toolHistory
toolHistory,
commentReadState
),
[
teamData,
@ -64,6 +68,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
activeTools,
finishedVisible,
toolHistory,
commentReadState,
]
);
}

View file

@ -7,10 +7,12 @@
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { Ban, ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
import type { GraphNode } from '@claude-teams/agent-graph';
import { GraphTaskCard } from './GraphTaskCard';
// ─── Tool name/preview formatters ───────────────────────────────────────────
/** Clean up tool names: "mcp__agent-teams__task_create" → "Task Create" */
@ -44,20 +46,38 @@ function formatToolPreview(preview: string | undefined): string | undefined {
interface GraphNodePopoverProps {
node: GraphNode;
teamName: string;
onClose: () => void;
onSendMessage?: (memberName: string) => void;
onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (memberName: string) => void;
onCreateTask?: (owner: string) => void;
onStartTask?: (taskId: string) => void;
onCompleteTask?: (taskId: string) => void;
onApproveTask?: (taskId: string) => void;
onRequestReview?: (taskId: string) => void;
onRequestChanges?: (taskId: string) => void;
onCancelTask?: (taskId: string) => void;
onMoveBackToDone?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
export const GraphNodePopover = ({
node,
teamName,
onClose,
onSendMessage,
onOpenTaskDetail,
onOpenMemberProfile,
onCreateTask,
onStartTask,
onCompleteTask,
onApproveTask,
onRequestReview,
onRequestChanges,
onCancelTask,
onMoveBackToDone,
onDeleteTask,
}: GraphNodePopoverProps): React.JSX.Element => {
if (node.kind === 'member' || node.kind === 'lead') {
return (
@ -73,7 +93,22 @@ export const GraphNodePopover = ({
}
if (node.kind === 'task') {
return <TaskPopoverContent node={node} onClose={onClose} onOpenDetail={onOpenTaskDetail} />;
return (
<GraphTaskCard
node={node}
teamName={teamName}
onClose={onClose}
onOpenDetail={onOpenTaskDetail}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onApproveTask={onApproveTask}
onRequestReview={onRequestReview}
onRequestChanges={onRequestChanges}
onCancelTask={onCancelTask}
onMoveBackToDone={onMoveBackToDone}
onDeleteTask={onDeleteTask}
/>
);
}
// Process
@ -333,99 +368,3 @@ const MemberPopoverContent = ({
</div>
);
};
// ─── Task Popover ───────────────────────────────────────────────────────────
const TaskPopoverContent = ({
node,
onClose,
onOpenDetail,
}: {
node: GraphNode;
onClose: () => void;
onOpenDetail?: (taskId: string) => void;
}): React.JSX.Element => {
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
const statusColor =
node.taskStatus === 'in_progress'
? 'text-blue-400 border-blue-500/30'
: node.taskStatus === 'completed'
? 'text-emerald-400 border-emerald-500/30'
: 'text-zinc-400 border-zinc-500/30';
const reviewColor =
node.reviewState === 'review'
? 'text-amber-400 border-amber-500/30'
: node.reviewState === 'needsFix'
? 'text-red-400 border-red-500/30'
: node.reviewState === 'approved'
? 'text-emerald-400 border-emerald-500/30'
: '';
return (
<div className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div className="font-mono text-sm font-bold text-[var(--color-text)]">
{node.displayId ?? node.label}
</div>
{node.sublabel && (
<div className="mt-0.5 truncate text-xs text-[var(--color-text-muted)]">
{node.sublabel}
</div>
)}
<div className="mt-2 flex flex-wrap gap-1">
<Badge variant="outline" className={`px-1.5 py-0 text-[10px] ${statusColor}`}>
{node.taskStatus ?? 'pending'}
</Badge>
{node.reviewState && node.reviewState !== 'none' && (
<Badge variant="outline" className={`px-1.5 py-0 text-[10px] ${reviewColor}`}>
{node.reviewState}
</Badge>
)}
{node.isBlocked && (
<Badge
variant="outline"
className="border-red-500/30 px-1.5 py-0 text-[10px] text-red-400"
>
<Ban size={10} className="mr-0.5" /> blocked
</Badge>
)}
{node.needsClarification && (
<Badge
variant="outline"
className="border-red-500/30 px-1.5 py-0 text-[10px] text-red-400"
>
needs clarification
</Badge>
)}
</div>
{/* Task dependencies */}
{node.blockedByDisplayIds && node.blockedByDisplayIds.length > 0 && (
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
<span className="text-red-400">Blocked by:</span> {node.blockedByDisplayIds.join(', ')}
</div>
)}
{node.blocksDisplayIds && node.blocksDisplayIds.length > 0 && (
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
<span className="text-amber-400">Blocks:</span> {node.blocksDisplayIds.join(', ')}
</div>
)}
<div className="mt-3">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => {
onOpenDetail?.(taskId);
onClose();
}}
>
<ExternalLink size={12} /> Open task
</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,150 @@
/**
* GraphTaskCard wraps the REAL KanbanTaskCard with graph-specific glow/pulse effects.
* Lives in features/ so it CAN import from @renderer/.
*/
import { useMemo } from 'react';
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
import { useStore } from '@renderer/store';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
// ─── Types ──────────────────────────────────────────────────────────────────
interface GraphTaskCardProps {
node: GraphNode;
teamName: string;
onClose: () => void;
onOpenDetail?: (taskId: string) => void;
onStartTask?: (taskId: string) => void;
onCompleteTask?: (taskId: string) => void;
onApproveTask?: (taskId: string) => void;
onRequestReview?: (taskId: string) => void;
onRequestChanges?: (taskId: string) => void;
onCancelTask?: (taskId: string) => void;
onMoveBackToDone?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function resolveColumn(task: TeamTask): KanbanColumnId {
if (task.reviewState === 'approved') return 'approved';
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'completed') return 'done';
return 'todo';
}
function getGlowStyle(task: TeamTask): React.CSSProperties {
const col = resolveColumn(task);
const blocked = (task.blockedBy?.length ?? 0) > 0;
if (blocked) {
return { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' };
}
switch (col) {
case 'in_progress':
return {
boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)',
};
case 'review':
return task.reviewState === 'needsFix'
? { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' }
: { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' };
case 'approved':
return { boxShadow: '0 0 10px rgba(34, 197, 94, 0.3)' };
default:
return {};
}
}
function getPulseClass(task: TeamTask): string {
const col = resolveColumn(task);
if (col === 'in_progress' || col === 'review') return 'animate-pulse';
return '';
}
// ─── Main Component ─────────────────────────────────────────────────────────
export const GraphTaskCard = ({
node,
teamName,
onClose,
onOpenDetail,
onStartTask,
onCompleteTask,
onApproveTask,
onRequestReview,
onRequestChanges,
onCancelTask,
onMoveBackToDone,
onDeleteTask,
}: GraphTaskCardProps): React.JSX.Element => {
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
const task = useStore((s) => s.selectedTeamData?.tasks.find((t) => t.id === taskId));
const tasks = useStore((s) => s.selectedTeamData?.tasks ?? []);
const members = useStore((s) => s.selectedTeamData?.members ?? []);
const taskMap = useMemo(() => {
const map = new Map<string, TeamTask>();
for (const t of tasks) map.set(t.id, t);
return map;
}, [tasks]);
const memberColorMap = useMemo(() => {
const map = new Map<string, string>();
for (const m of members) {
if (m.color) map.set(m.name, m.color);
}
return map;
}, [members]);
if (!task) {
return (
<div className="min-w-[200px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div className="font-mono text-sm text-[var(--color-text)]">
{node.displayId ?? node.label}
</div>
</div>
);
}
const columnId = resolveColumn(task);
const taskWithKanban = task as TeamTaskWithKanban;
const closeAct = (fn?: (id: string) => void) => (taskId: string) => {
fn?.(taskId);
onClose();
};
return (
<div
className={`min-w-[260px] max-w-[320px] rounded-lg shadow-2xl ${getPulseClass(task)}`}
style={getGlowStyle(task)}
>
<KanbanTaskCard
task={taskWithKanban}
teamName={teamName}
columnId={columnId}
hasReviewers={false}
taskMap={taskMap}
memberColorMap={memberColorMap}
onTaskClick={() => {
onOpenDetail?.(taskId);
onClose();
}}
onStartTask={closeAct(onStartTask)}
onCompleteTask={closeAct(onCompleteTask)}
onApprove={closeAct(onApproveTask)}
onRequestReview={closeAct(onRequestReview)}
onRequestChanges={closeAct(onRequestChanges)}
onCancelTask={closeAct(onCancelTask)}
onMoveBackToDone={closeAct(onMoveBackToDone)}
onDeleteTask={onDeleteTask ? closeAct(onDeleteTask) : undefined}
/>
</div>
);
};

View file

@ -3,7 +3,7 @@
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
*/
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
@ -33,6 +33,26 @@ export const TeamGraphOverlay = ({
}: TeamGraphOverlayProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
// Task action dispatchers (same pattern as TeamGraphTab)
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>
window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })),
[teamName]
);
const taskActions = useMemo(
() => ({
onStartTask: dispatchTaskAction('start-task'),
onCompleteTask: dispatchTaskAction('complete-task'),
onApproveTask: dispatchTaskAction('approve-task'),
onRequestReview: dispatchTaskAction('request-review'),
onRequestChanges: dispatchTaskAction('request-changes'),
onCancelTask: dispatchTaskAction('cancel-task'),
onMoveBackToDone: dispatchTaskAction('move-back-to-done'),
onDeleteTask: dispatchTaskAction('delete-task'),
}),
[dispatchTaskAction]
);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {
@ -67,6 +87,7 @@ export const TeamGraphOverlay = ({
renderOverlay={({ node, onClose: closePopover }) => (
<GraphNodePopover
node={node}
teamName={teamName}
onClose={closePopover}
onSendMessage={(name) => {
onSendMessage?.(name);
@ -80,6 +101,7 @@ export const TeamGraphOverlay = ({
onOpenMemberProfile?.(name);
closePopover();
}}
{...taskActions}
/>
)}
/>

View file

@ -3,7 +3,7 @@
* Provides Fullscreen button that opens the overlay.
*/
import { lazy, Suspense, useCallback, useState } from 'react';
import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
@ -58,6 +58,36 @@ export const TeamGraphTab = ({
[teamName]
);
// Task action dispatchers
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>
window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })),
[teamName]
);
const dispatchStartTask = useMemo(() => dispatchTaskAction('start-task'), [dispatchTaskAction]);
const dispatchCompleteTask = useMemo(
() => dispatchTaskAction('complete-task'),
[dispatchTaskAction]
);
const dispatchApproveTask = useMemo(
() => dispatchTaskAction('approve-task'),
[dispatchTaskAction]
);
const dispatchRequestReview = useMemo(
() => dispatchTaskAction('request-review'),
[dispatchTaskAction]
);
const dispatchRequestChanges = useMemo(
() => dispatchTaskAction('request-changes'),
[dispatchTaskAction]
);
const dispatchCancelTask = useMemo(() => dispatchTaskAction('cancel-task'), [dispatchTaskAction]);
const dispatchMoveBackToDone = useMemo(
() => dispatchTaskAction('move-back-to-done'),
[dispatchTaskAction]
);
const dispatchDeleteTask = useMemo(() => dispatchTaskAction('delete-task'), [dispatchTaskAction]);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {
@ -89,11 +119,20 @@ export const TeamGraphTab = ({
renderOverlay={({ node, onClose }) => (
<GraphNodePopover
node={node}
teamName={teamName}
onClose={onClose}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
onCreateTask={dispatchCreateTask}
onStartTask={dispatchStartTask}
onCompleteTask={dispatchCompleteTask}
onApproveTask={dispatchApproveTask}
onRequestReview={dispatchRequestReview}
onRequestChanges={dispatchRequestChanges}
onCancelTask={dispatchCancelTask}
onMoveBackToDone={dispatchMoveBackToDone}
onDeleteTask={dispatchDeleteTask}
/>
)}
/>

View file

@ -484,6 +484,56 @@ function collectTaskChangeInvalidationState(
};
}
function preserveKnownTaskChangePresence(
teamName: string,
prevTasks: TeamData['tasks'] | null | undefined,
nextTasks: TeamData['tasks']
): TeamData['tasks'] {
if (!Array.isArray(prevTasks) || prevTasks.length === 0 || nextTasks.length === 0) {
return nextTasks;
}
const prevTaskById = new Map(prevTasks.map((task) => [task.id, task]));
let changed = false;
const mergedTasks = nextTasks.map((task) => {
if (task.changePresence && task.changePresence !== 'unknown') {
return task;
}
const previousTask = prevTaskById.get(task.id);
if (
!previousTask ||
!previousTask.changePresence ||
previousTask.changePresence === 'unknown'
) {
return task;
}
const previousKey = buildTaskChangePresenceKey(
teamName,
previousTask.id,
buildTaskChangeRequestOptions(previousTask)
);
const nextKey = buildTaskChangePresenceKey(
teamName,
task.id,
buildTaskChangeRequestOptions(task)
);
if (previousKey !== nextKey) {
return task;
}
changed = true;
return {
...task,
changePresence: previousTask.changePresence,
};
});
return changed ? mergedTasks : nextTasks;
}
function mapSendMessageError(error: unknown): string {
const message =
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
@ -1333,7 +1383,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set({
selectedTeamName: teamName,
selectedTeamData: data,
selectedTeamData: previousData
? {
...data,
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
}
: data,
selectedTeamLoading: false,
selectedTeamError: null,
});
@ -1454,7 +1509,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return;
}
set({
selectedTeamData: data,
selectedTeamData: previousData
? {
...data,
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
}
: data,
selectedTeamError: null,
});
const invalidationState = previousData

View file

@ -393,6 +393,62 @@ describe('teamSlice actions', () => {
expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1);
expect(warmTaskChangeSummaries).not.toHaveBeenCalled();
});
it('preserves known task changePresence across refresh when task change signature is unchanged', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'task-1',
subject: 'Known changes',
status: 'in_progress',
owner: 'alice',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
comments: [],
attachments: [],
changePresence: 'has_changes',
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
},
});
hoisted.getData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [
{
id: 'task-1',
subject: 'Known changes',
status: 'in_progress',
owner: 'alice',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
comments: [],
attachments: [],
changePresence: 'unknown',
},
],
members: [],
messages: [{ from: 'team-lead', text: 'Ping', timestamp: '2026-03-01T10:10:00.000Z' }],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
});
await store.getState().refreshTeamData('my-team');
expect(store.getState().selectedTeamData?.tasks[0]?.changePresence).toBe('has_changes');
});
});
describe('provisioning run scoping', () => {