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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
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`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
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).
|
||||
*/
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue