diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index 9d970f9f..04184bf8 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -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`; diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts index 2c76c940..81cdf5be 100644 --- a/packages/agent-graph/src/hooks/useGraphInteraction.ts +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -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; + } } }, []); diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 94fd1daa..e8bb24e4 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -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; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index dafceec0..67b1c9a3 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -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 */ diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 78bd2200..90bd07a2 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -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 { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index bf84bd5a..59087c61 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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); }; }); diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 89b2a36d..c018666e 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -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, activeTools?: Record>, finishedVisible?: Record>, - toolHistory?: Record + toolHistory?: Record, + commentReadState?: Record ): 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 + ): void { // Build lookup tables for fast resolution const completedTaskIds = new Set(); const taskDisplayIds = new Map(); @@ -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[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 }, }); diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts index 6356d0c4..9c755038 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -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, ] ); } diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 0dab486b..ad938423 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -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 ; + return ( + + ); } // Process @@ -333,99 +368,3 @@ const MemberPopoverContent = ({ ); }; - -// ─── 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 ( -
-
- {node.displayId ?? node.label} -
- {node.sublabel && ( -
- {node.sublabel} -
- )} - -
- - {node.taskStatus ?? 'pending'} - - {node.reviewState && node.reviewState !== 'none' && ( - - {node.reviewState} - - )} - {node.isBlocked && ( - - blocked - - )} - {node.needsClarification && ( - - needs clarification - - )} -
- - {/* Task dependencies */} - {node.blockedByDisplayIds && node.blockedByDisplayIds.length > 0 && ( -
- Blocked by: {node.blockedByDisplayIds.join(', ')} -
- )} - {node.blocksDisplayIds && node.blocksDisplayIds.length > 0 && ( -
- Blocks: {node.blocksDisplayIds.join(', ')} -
- )} - -
- -
-
- ); -}; diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx new file mode 100644 index 00000000..3a2f7a4b --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx @@ -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(); + for (const t of tasks) map.set(t.id, t); + return map; + }, [tasks]); + + const memberColorMap = useMemo(() => { + const map = new Map(); + for (const m of members) { + if (m.color) map.set(m.name, m.color); + } + return map; + }, [members]); + + if (!task) { + return ( +
+
+ {node.displayId ?? node.label} +
+
+ ); + } + + const columnId = resolveColumn(task); + const taskWithKanban = task as TeamTaskWithKanban; + + const closeAct = (fn?: (id: string) => void) => (taskId: string) => { + fn?.(taskId); + onClose(); + }; + + return ( +
+ { + 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} + /> +
+ ); +}; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 7f4e6d30..84fe1f8b 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -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 }) => ( { onSendMessage?.(name); @@ -80,6 +101,7 @@ export const TeamGraphOverlay = ({ onOpenMemberProfile?.(name); closePopover(); }} + {...taskActions} /> )} /> diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 4580c21f..2f3c5370 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -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 }) => ( )} /> diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index f6555d2a..c4e1952d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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 = (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 = (set, return; } set({ - selectedTeamData: data, + selectedTeamData: previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data, selectedTeamError: null, }); const invalidationState = previousData diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index b58f5639..e2bea511 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -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', () => {