feat(graph): current task indicator + working spinner on member nodes

Popover:
- Shows "working on [task subject]" with spinning Loader2 when member
  has currentTaskId (same pattern as CurrentTaskIndicator in MemberCard)
- Click task subject → opens TaskDetailDialog

Canvas:
- Active members with currentTaskId get subtle spinning arc around hexagon
- Spinner only shows when state is active/thinking/tool_calling

Adapter:
- Passes currentTaskId + currentTaskSubject from ResolvedTeamMember
- Looks up task subject from data.tasks

Port types:
- Added currentTaskId, currentTaskSubject to GraphNode
This commit is contained in:
iliya 2026-03-28 15:18:23 +02:00
parent add9df2006
commit ee5b7b5888
4 changed files with 48 additions and 1 deletions

View file

@ -50,6 +50,17 @@ export function drawAgents(
// Breathing animation + spawn/waiting effects
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
// Working indicator: subtle spinning arc when member has active task
if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) {
const ringR = r + 4;
const rotation = time * 1.5;
ctx.beginPath();
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 0.8);
ctx.strokeStyle = hexWithAlpha(color, 0.4);
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Name + role label (single line: "jack · developer")
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
drawLabel(ctx, x, y, r, labelText, color);

View file

@ -49,6 +49,10 @@ export interface GraphNode {
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
/** Context window usage ratio (0..1), available for lead only */
contextUsage?: number;
/** Current task ID this member is working on */
currentTaskId?: string | null;
/** Current task subject (for display in popover) */
currentTaskSubject?: string;
// ─── Task-specific ─────────────────────────────────────────────────────
/** Short display ID (e.g., "#3") */

View file

@ -158,6 +158,10 @@ export class TeamGraphAdapter {
role: member.role ?? undefined,
spawnStatus: spawn?.status,
avatarUrl: agentAvatarUrl(member.name, 64),
currentTaskId: member.currentTaskId ?? undefined,
currentTaskSubject: member.currentTaskId
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
: undefined,
domainRef: { kind: 'member', teamName, memberName: member.name },
});

View file

@ -7,7 +7,7 @@
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { MessageSquare, ExternalLink, User, Plus } from 'lucide-react';
import { Loader2, MessageSquare, ExternalLink, User, Plus } from 'lucide-react';
import type { GraphNode } from '@claude-teams/agent-graph';
@ -36,6 +36,7 @@ export function GraphNodePopover({
onSendMessage={onSendMessage}
onOpenProfile={onOpenMemberProfile}
onCreateTask={onCreateTask}
onOpenTask={onOpenTaskDetail}
/>
);
}
@ -70,12 +71,14 @@ function MemberPopoverContent({
onSendMessage,
onOpenProfile,
onCreateTask,
onOpenTask,
}: {
node: GraphNode;
onClose: () => void;
onSendMessage?: (name: string) => void;
onOpenProfile?: (name: string) => void;
onCreateTask?: (owner: string) => void;
onOpenTask?: (taskId: string) => void;
}): React.JSX.Element {
const memberName = node.domainRef.kind === 'member' ? node.domainRef.memberName : 'team-lead';
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
@ -171,6 +174,31 @@ function MemberPopoverContent({
</div>
)}
{/* Current task indicator — reuses same pattern as MemberCard */}
{node.currentTaskId && node.currentTaskSubject && (
<div className="mt-2 flex items-center gap-1.5 text-[10px]">
<Loader2
className="size-3 shrink-0 animate-spin"
style={{ color: node.color ?? '#66ccff' }}
/>
<span className="shrink-0 text-[var(--color-text-muted)]">working on</span>
<button
type="button"
className="min-w-0 truncate rounded px-1.5 py-0.5 font-medium text-[var(--color-text)] transition-opacity hover:opacity-90"
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(node.currentTaskId!);
onClose();
}}
>
{node.currentTaskSubject.length > 30
? `${node.currentTaskSubject.slice(0, 30)}`
: node.currentTaskSubject}
</button>
</div>
)}
{/* Actions */}
<div className="mt-3 flex flex-wrap gap-1.5">
<Button