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:
parent
8fe1487708
commit
16f069fae3
14 changed files with 626 additions and 152 deletions
|
|
@ -42,10 +42,8 @@ export function drawTasks(
|
||||||
|
|
||||||
// ─── Private ────────────────────────────────────────────────────────────────
|
// ─── Private ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getTaskOpacity(node: GraphNode): number {
|
function getTaskOpacity(_node: GraphNode): number {
|
||||||
if (node.taskStatus === 'deleted') return 0;
|
if (_node.taskStatus === 'deleted') return 0;
|
||||||
if (node.reviewState === 'approved') return 0.65;
|
|
||||||
if (node.taskStatus === 'completed') return 0.45;
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,27 +140,16 @@ function drawTaskPill(
|
||||||
ctx.stroke();
|
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)
|
// Subject (main title — large)
|
||||||
if (node.sublabel) {
|
if (node.sublabel) {
|
||||||
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
|
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillStyle = isFinished ? COLORS.textDim : COLORS.textPrimary;
|
ctx.fillStyle = COLORS.textPrimary;
|
||||||
const maxW = w - TASK_PILL.textOffsetX - 8;
|
const textX = -halfW + 10;
|
||||||
|
const maxW = w - 18;
|
||||||
const subject = truncateText(ctx, node.sublabel, maxW, ctx.font);
|
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)
|
// Display ID (secondary — small)
|
||||||
|
|
@ -170,8 +157,8 @@ function drawTaskPill(
|
||||||
ctx.font = `${TASK_PILL.subjectFontSize}px monospace`;
|
ctx.font = `${TASK_PILL.subjectFontSize}px monospace`;
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillStyle = isFinished ? COLORS.textMuted : COLORS.textDim;
|
ctx.fillStyle = COLORS.textDim;
|
||||||
ctx.fillText(displayId, -halfW + TASK_PILL.textOffsetX, 8);
|
ctx.fillText(displayId, -halfW + 10, 8);
|
||||||
|
|
||||||
// Approved badge: checkmark at right side
|
// Approved badge: checkmark at right side
|
||||||
if (node.reviewState === 'approved') {
|
if (node.reviewState === 'approved') {
|
||||||
|
|
@ -182,14 +169,47 @@ function drawTaskPill(
|
||||||
ctx.fillText('\u2713', halfW - 8, 0); // ✓
|
ctx.fillText('\u2713', halfW - 8, 0); // ✓
|
||||||
}
|
}
|
||||||
|
|
||||||
// Completed: subtle strikethrough line
|
// Comment count badge — on the bottom-right border edge, 1.5x bigger
|
||||||
if (node.taskStatus === 'completed' && node.reviewState !== 'approved') {
|
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.beginPath();
|
||||||
ctx.moveTo(-halfW + TASK_PILL.textOffsetX, 0);
|
ctx.roundRect(badgeX - bw / 2, badgeY - bh / 2, bw, bh, 3);
|
||||||
ctx.lineTo(halfW - 10, 0);
|
ctx.fill();
|
||||||
ctx.strokeStyle = COLORS.textMuted;
|
// Tail pointing up-left
|
||||||
ctx.lineWidth = 0.5;
|
ctx.beginPath();
|
||||||
ctx.stroke();
|
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();
|
ctx.restore();
|
||||||
|
|
@ -203,6 +223,28 @@ export function drawColumnHeaders(
|
||||||
zones: KanbanZoneInfo[],
|
zones: KanbanZoneInfo[],
|
||||||
): void {
|
): void {
|
||||||
for (const zone of zones) {
|
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) {
|
for (const header of zone.headers) {
|
||||||
ctx.font = 'bold 8px monospace';
|
ctx.font = 'bold 8px monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
|
|
@ -210,15 +252,6 @@ export function drawColumnHeaders(
|
||||||
ctx.fillStyle = hexWithAlpha(header.color, 0.6);
|
ctx.fillStyle = hexWithAlpha(header.color, 0.6);
|
||||||
ctx.fillText(header.label, header.x, header.y - 2);
|
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"
|
// Overflow badge: "+N more"
|
||||||
if (header.overflowCount > 0) {
|
if (header.overflowCount > 0) {
|
||||||
const badgeText = `+${header.overflowCount} more`;
|
const badgeText = `+${header.overflowCount} more`;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,11 @@ export function useGraphInteraction(
|
||||||
clickedNodeId.current = hit;
|
clickedNodeId.current = hit;
|
||||||
|
|
||||||
if (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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export class KanbanLayoutEngine {
|
||||||
if (zoneInfo) this.zones.push(zoneInfo);
|
if (zoneInfo) this.zones.push(zoneInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
KanbanLayoutEngine.#layoutUnassigned(unassigned);
|
KanbanLayoutEngine.#layoutUnassigned(unassigned, nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Private ──────────────────────────────────────────────────────────────
|
// ─── 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;
|
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()) {
|
for (const [idx, task] of tasks.entries()) {
|
||||||
const targetX = -400 + (idx % 3) * columnWidth;
|
const col = idx % cols;
|
||||||
const targetY = 400 + Math.floor(idx / 3) * rowHeight;
|
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.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.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
|
||||||
task.fx = task.x;
|
task.fx = task.x;
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@ export interface GraphNode {
|
||||||
blockedByDisplayIds?: string[];
|
blockedByDisplayIds?: string[];
|
||||||
/** Display IDs of tasks this one blocks */
|
/** Display IDs of tasks this one blocks */
|
||||||
blocksDisplayIds?: string[];
|
blocksDisplayIds?: string[];
|
||||||
|
/** Total comment count on this task */
|
||||||
|
totalCommentCount?: number;
|
||||||
|
/** Unread comment count on this task */
|
||||||
|
unreadCommentCount?: number;
|
||||||
|
|
||||||
// ─── Process-specific ──────────────────────────────────────────────────
|
// ─── Process-specific ──────────────────────────────────────────────────
|
||||||
/** Clickable URL for process */
|
/** Clickable URL for process */
|
||||||
|
|
|
||||||
|
|
@ -248,8 +248,9 @@ export function GraphView({
|
||||||
// Check if we hit a node
|
// Check if we hit a node
|
||||||
interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes);
|
interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes);
|
||||||
|
|
||||||
if (interaction.dragNodeId.current) {
|
// Hit a node (draggable or clickable) → don't pan
|
||||||
// Hit a node → will drag it
|
const hitNode = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes);
|
||||||
|
if (hitNode) {
|
||||||
markUserInteracted();
|
markUserInteracted();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -268,11 +268,107 @@ export const TeamDetailView = ({
|
||||||
window.addEventListener('graph:send-message', onSendMsg);
|
window.addEventListener('graph:send-message', onSendMsg);
|
||||||
window.addEventListener('graph:open-profile', onOpenProfile);
|
window.addEventListener('graph:open-profile', onOpenProfile);
|
||||||
window.addEventListener('graph:create-task', onCreateTask);
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener('graph:open-task', onOpenTask);
|
window.removeEventListener('graph:open-task', onOpenTask);
|
||||||
window.removeEventListener('graph:send-message', onSendMsg);
|
window.removeEventListener('graph:send-message', onSendMsg);
|
||||||
window.removeEventListener('graph:open-profile', onOpenProfile);
|
window.removeEventListener('graph:open-profile', onOpenProfile);
|
||||||
window.removeEventListener('graph:create-task', onCreateTask);
|
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);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
* Class-based with ES #private fields, caching, and DI-ready constructor.
|
* Class-based with ES #private fields, caching, and DI-ready constructor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
import { getInboxJsonType, isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { getInboxJsonType, isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
@ -60,7 +61,8 @@ export class TeamGraphAdapter {
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
toolHistory?: Record<string, ActiveToolCall[]>
|
toolHistory?: Record<string, ActiveToolCall[]>,
|
||||||
|
commentReadState?: Record<string, unknown>
|
||||||
): GraphDataPort {
|
): GraphDataPort {
|
||||||
if (teamData?.teamName !== teamName) {
|
if (teamData?.teamName !== teamName) {
|
||||||
return TeamGraphAdapter.#emptyResult(teamName);
|
return TeamGraphAdapter.#emptyResult(teamName);
|
||||||
|
|
@ -144,7 +146,7 @@ export class TeamGraphAdapter {
|
||||||
.sort()
|
.sort()
|
||||||
.join('|')
|
.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) {
|
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
|
||||||
return this.#cachedResult;
|
return this.#cachedResult;
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +193,7 @@ export class TeamGraphAdapter {
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
toolHistory
|
toolHistory
|
||||||
);
|
);
|
||||||
this.#buildTaskNodes(nodes, edges, teamData, teamName);
|
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
||||||
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, leadName, edges);
|
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, leadName, edges);
|
||||||
this.#buildCommentParticles(particles, teamData, 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
|
// Build lookup tables for fast resolution
|
||||||
const completedTaskIds = new Set<string>();
|
const completedTaskIds = new Set<string>();
|
||||||
const taskDisplayIds = new Map<string, 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)}`)
|
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||||
: undefined;
|
: 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({
|
nodes.push({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
kind: 'task',
|
kind: 'task',
|
||||||
|
|
@ -410,6 +429,8 @@ export class TeamGraphAdapter {
|
||||||
isBlocked,
|
isBlocked,
|
||||||
blockedByDisplayIds,
|
blockedByDisplayIds,
|
||||||
blocksDisplayIds,
|
blocksDisplayIds,
|
||||||
|
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
||||||
|
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
||||||
domainRef: { kind: 'task', teamName, taskId: task.id },
|
domainRef: { kind: 'task', teamName, taskId: task.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
* Thin wrapper — instantiates the class adapter and calls adapt() with store data.
|
* 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 { useStore } from '@renderer/store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -43,6 +44,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
return agents;
|
return agents;
|
||||||
}, [pendingApprovals]);
|
}, [pendingApprovals]);
|
||||||
|
|
||||||
|
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
adapterRef.current.adapt(
|
adapterRef.current.adapt(
|
||||||
|
|
@ -53,7 +56,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
pendingApprovalAgents,
|
pendingApprovalAgents,
|
||||||
activeTools,
|
activeTools,
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
toolHistory
|
toolHistory,
|
||||||
|
commentReadState
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
teamData,
|
teamData,
|
||||||
|
|
@ -64,6 +68,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
activeTools,
|
activeTools,
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
toolHistory,
|
toolHistory,
|
||||||
|
commentReadState,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
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 type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
|
import { GraphTaskCard } from './GraphTaskCard';
|
||||||
|
|
||||||
// ─── Tool name/preview formatters ───────────────────────────────────────────
|
// ─── Tool name/preview formatters ───────────────────────────────────────────
|
||||||
|
|
||||||
/** Clean up tool names: "mcp__agent-teams__task_create" → "Task Create" */
|
/** Clean up tool names: "mcp__agent-teams__task_create" → "Task Create" */
|
||||||
|
|
@ -44,20 +46,38 @@ function formatToolPreview(preview: string | undefined): string | undefined {
|
||||||
|
|
||||||
interface GraphNodePopoverProps {
|
interface GraphNodePopoverProps {
|
||||||
node: GraphNode;
|
node: GraphNode;
|
||||||
|
teamName: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSendMessage?: (memberName: string) => void;
|
onSendMessage?: (memberName: string) => void;
|
||||||
onOpenTaskDetail?: (taskId: string) => void;
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
onOpenMemberProfile?: (memberName: string) => void;
|
onOpenMemberProfile?: (memberName: string) => void;
|
||||||
onCreateTask?: (owner: 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 = ({
|
export const GraphNodePopover = ({
|
||||||
node,
|
node,
|
||||||
|
teamName,
|
||||||
onClose,
|
onClose,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onOpenTaskDetail,
|
onOpenTaskDetail,
|
||||||
onOpenMemberProfile,
|
onOpenMemberProfile,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
|
onStartTask,
|
||||||
|
onCompleteTask,
|
||||||
|
onApproveTask,
|
||||||
|
onRequestReview,
|
||||||
|
onRequestChanges,
|
||||||
|
onCancelTask,
|
||||||
|
onMoveBackToDone,
|
||||||
|
onDeleteTask,
|
||||||
}: GraphNodePopoverProps): React.JSX.Element => {
|
}: GraphNodePopoverProps): React.JSX.Element => {
|
||||||
if (node.kind === 'member' || node.kind === 'lead') {
|
if (node.kind === 'member' || node.kind === 'lead') {
|
||||||
return (
|
return (
|
||||||
|
|
@ -73,7 +93,22 @@ export const GraphNodePopover = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.kind === 'task') {
|
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
|
// Process
|
||||||
|
|
@ -333,99 +368,3 @@ const MemberPopoverContent = ({
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
150
src/renderer/features/agent-graph/ui/GraphTaskCard.tsx
Normal file
150
src/renderer/features/agent-graph/ui/GraphTaskCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
|
|
@ -33,6 +33,26 @@ export const TeamGraphOverlay = ({
|
||||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
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 = {
|
const events: GraphEventPort = {
|
||||||
onNodeDoubleClick: useCallback(
|
onNodeDoubleClick: useCallback(
|
||||||
(ref: GraphDomainRef) => {
|
(ref: GraphDomainRef) => {
|
||||||
|
|
@ -67,6 +87,7 @@ export const TeamGraphOverlay = ({
|
||||||
renderOverlay={({ node, onClose: closePopover }) => (
|
renderOverlay={({ node, onClose: closePopover }) => (
|
||||||
<GraphNodePopover
|
<GraphNodePopover
|
||||||
node={node}
|
node={node}
|
||||||
|
teamName={teamName}
|
||||||
onClose={closePopover}
|
onClose={closePopover}
|
||||||
onSendMessage={(name) => {
|
onSendMessage={(name) => {
|
||||||
onSendMessage?.(name);
|
onSendMessage?.(name);
|
||||||
|
|
@ -80,6 +101,7 @@ export const TeamGraphOverlay = ({
|
||||||
onOpenMemberProfile?.(name);
|
onOpenMemberProfile?.(name);
|
||||||
closePopover();
|
closePopover();
|
||||||
}}
|
}}
|
||||||
|
{...taskActions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Provides Fullscreen button that opens the overlay.
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
|
|
@ -58,6 +58,36 @@ export const TeamGraphTab = ({
|
||||||
[teamName]
|
[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 = {
|
const events: GraphEventPort = {
|
||||||
onNodeDoubleClick: useCallback(
|
onNodeDoubleClick: useCallback(
|
||||||
(ref: GraphDomainRef) => {
|
(ref: GraphDomainRef) => {
|
||||||
|
|
@ -89,11 +119,20 @@ export const TeamGraphTab = ({
|
||||||
renderOverlay={({ node, onClose }) => (
|
renderOverlay={({ node, onClose }) => (
|
||||||
<GraphNodePopover
|
<GraphNodePopover
|
||||||
node={node}
|
node={node}
|
||||||
|
teamName={teamName}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSendMessage={dispatchSendMessage}
|
onSendMessage={dispatchSendMessage}
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
onOpenTaskDetail={dispatchOpenTask}
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
onOpenMemberProfile={dispatchOpenProfile}
|
||||||
onCreateTask={dispatchCreateTask}
|
onCreateTask={dispatchCreateTask}
|
||||||
|
onStartTask={dispatchStartTask}
|
||||||
|
onCompleteTask={dispatchCompleteTask}
|
||||||
|
onApproveTask={dispatchApproveTask}
|
||||||
|
onRequestReview={dispatchRequestReview}
|
||||||
|
onRequestChanges={dispatchRequestChanges}
|
||||||
|
onCancelTask={dispatchCancelTask}
|
||||||
|
onMoveBackToDone={dispatchMoveBackToDone}
|
||||||
|
onDeleteTask={dispatchDeleteTask}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function mapSendMessageError(error: unknown): string {
|
||||||
const message =
|
const message =
|
||||||
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
|
error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
|
||||||
|
|
@ -1333,7 +1383,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
set({
|
set({
|
||||||
selectedTeamName: teamName,
|
selectedTeamName: teamName,
|
||||||
selectedTeamData: data,
|
selectedTeamData: previousData
|
||||||
|
? {
|
||||||
|
...data,
|
||||||
|
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
|
||||||
|
}
|
||||||
|
: data,
|
||||||
selectedTeamLoading: false,
|
selectedTeamLoading: false,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
});
|
});
|
||||||
|
|
@ -1454,7 +1509,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set({
|
set({
|
||||||
selectedTeamData: data,
|
selectedTeamData: previousData
|
||||||
|
? {
|
||||||
|
...data,
|
||||||
|
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
|
||||||
|
}
|
||||||
|
: data,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
});
|
});
|
||||||
const invalidationState = previousData
|
const invalidationState = previousData
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,62 @@ describe('teamSlice actions', () => {
|
||||||
expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1);
|
expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1);
|
||||||
expect(warmTaskChangeSummaries).not.toHaveBeenCalled();
|
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', () => {
|
describe('provisioning run scoping', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue