fix(team): re-add control_response via stdin for teammate permissions
Belt-and-suspenders approach:
1. Settings file: handles all FUTURE calls (teammate finds rule on retry)
2. control_response via stdin: may unblock CURRENT waiting prompt
(now includes updatedInput: {} which was the previous ZodError fix)
Without #2, approved teammates stay stuck until team restart because
the CLI doesn't hot-reload settings.local.json for pending prompts.
This commit is contained in:
parent
9a1ba76324
commit
bd242fac5a
10 changed files with 166 additions and 22 deletions
|
|
@ -52,7 +52,7 @@ export function drawAgents(
|
|||
|
||||
// Pending approval indicator: pulsing amber ring
|
||||
if (node.pendingApproval) {
|
||||
const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 3);
|
||||
const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 7);
|
||||
const ringR = r + 5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||
|
|
@ -367,20 +367,31 @@ function drawBreathing(
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Avatar image cache ─────────────────────────────────────────────────────
|
||||
// ─── Avatar image cache with LRU eviction ───────────────────────────────────
|
||||
|
||||
const AVATAR_CACHE_MAX = 100;
|
||||
const avatarCache = new Map<string, HTMLImageElement>();
|
||||
const avatarLoading = new Set<string>();
|
||||
|
||||
function getAvatarImage(url: string): HTMLImageElement | null {
|
||||
const cached = avatarCache.get(url);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
// Move to end (most recently used)
|
||||
avatarCache.delete(url);
|
||||
avatarCache.set(url, cached);
|
||||
return cached;
|
||||
}
|
||||
if (avatarLoading.has(url)) return null;
|
||||
|
||||
avatarLoading.add(url);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
// Evict oldest entry if over limit
|
||||
if (avatarCache.size >= AVATAR_CACHE_MAX) {
|
||||
const first = avatarCache.keys().next().value;
|
||||
if (first != null) avatarCache.delete(first);
|
||||
}
|
||||
avatarCache.set(url, img);
|
||||
avatarLoading.delete(url);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export interface VisualEffect {
|
|||
color: string;
|
||||
age: number;
|
||||
duration: number;
|
||||
/** Node radius for scaling the effect */
|
||||
nodeRadius?: number;
|
||||
particles?: ShatterParticle[];
|
||||
}
|
||||
|
||||
|
|
@ -29,8 +31,8 @@ interface ShatterParticle {
|
|||
/**
|
||||
* Create a spawn effect at position.
|
||||
*/
|
||||
export function createSpawnEffect(x: number, y: number, color: string): VisualEffect {
|
||||
return { type: 'spawn', x, y, color, age: 0, duration: 0.8 };
|
||||
export function createSpawnEffect(x: number, y: number, color: string, nodeRadius?: number): VisualEffect {
|
||||
return { type: 'spawn', x, y, color, age: 0, duration: 0.8, nodeRadius };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,7 +78,8 @@ export function drawEffects(
|
|||
|
||||
function drawSpawnEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void {
|
||||
const alpha = SPAWN_FX.maxAlpha * (1 - progress);
|
||||
const ringR = SPAWN_FX.ringStart + SPAWN_FX.ringExpand * progress;
|
||||
const baseR = fx.nodeRadius ?? SPAWN_FX.ringStart;
|
||||
const ringR = baseR + SPAWN_FX.ringExpand * progress;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
|
|
|||
|
|
@ -83,9 +83,11 @@ function drawTaskPill(
|
|||
ctx.translate(x, y);
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// Shadow — stronger for attention tasks
|
||||
ctx.shadowColor = hexWithAlpha(statusColor, 0.25);
|
||||
ctx.shadowBlur = needsAttention ? 12 : 4;
|
||||
// Shadow — stronger for attention tasks, red for blocked
|
||||
ctx.shadowColor = node.isBlocked
|
||||
? hexWithAlpha(COLORS.edgeBlocking, 0.3)
|
||||
: hexWithAlpha(statusColor, 0.25);
|
||||
ctx.shadowBlur = needsAttention || node.isBlocked ? 12 : 4;
|
||||
|
||||
// Background fill
|
||||
ctx.beginPath();
|
||||
|
|
@ -98,13 +100,26 @@ function drawTaskPill(
|
|||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Border
|
||||
// Border — red for blocked tasks
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(-halfW, -halfH, w, h, r);
|
||||
ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5);
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
if (node.isBlocked) {
|
||||
ctx.strokeStyle = hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.9 : 0.7);
|
||||
ctx.lineWidth = isSelected ? 2.5 : 1.8;
|
||||
} else {
|
||||
ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5);
|
||||
ctx.lineWidth = isSelected ? 2 : 1;
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Blocked indicator — red left stripe
|
||||
if (node.isBlocked) {
|
||||
ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(-halfW, -halfH, 4, h, [r, 0, 0, r]);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Review state overlay border — pulsing for review/needsFix, STATIC for approved
|
||||
if (reviewColor !== 'transparent') {
|
||||
ctx.beginPath();
|
||||
|
|
@ -203,6 +218,16 @@ export function drawColumnHeaders(
|
|||
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`;
|
||||
ctx.font = '7px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = hexWithAlpha(header.color, 0.45);
|
||||
ctx.fillText(badgeText, header.x, header.overflowY + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
type SimulationLinkDatum,
|
||||
} from 'd3-force';
|
||||
import type { GraphNode, GraphEdge, GraphParticle, GraphNodeKind } from '../ports/types';
|
||||
import { FORCE, ANIM_SPEED } from '../constants/canvas-constants';
|
||||
import { FORCE, ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
||||
import { getNodeStrategy } from '../strategies';
|
||||
import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects';
|
||||
import { getStateColor } from '../constants/colors';
|
||||
|
|
@ -200,7 +200,8 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
// New node appeared → spawn effect (only if truly new, never seen before).
|
||||
// Nodes returning from filter (e.g. Tasks toggle OFF→ON) are already in allKnown.
|
||||
if (!allKnown.has(node.id) && node.x != null && node.y != null) {
|
||||
state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state)));
|
||||
const nodeR = node.kind === 'lead' ? NODE.radiusLead : node.kind === 'member' ? NODE.radiusMember : undefined;
|
||||
state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeR));
|
||||
}
|
||||
|
||||
// Task completed → shatter effect
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ export interface KanbanColumnHeader {
|
|||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
/** Number of hidden overflow tasks in this column */
|
||||
overflowCount: number;
|
||||
/** Y position for the overflow badge */
|
||||
overflowY: number;
|
||||
}
|
||||
|
||||
/** Zone info per owner for rendering headers */
|
||||
|
|
@ -125,6 +129,8 @@ export class KanbanLayoutEngine {
|
|||
for (const [colIdx, col] of activeColumns.entries()) {
|
||||
const colX = baseX + colIdx * columnWidth;
|
||||
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
|
||||
const overflow = Math.max(0, col.tasks.length - maxVisibleRows);
|
||||
const visibleCount = Math.min(col.tasks.length, maxVisibleRows);
|
||||
|
||||
// Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y)
|
||||
headers.push({
|
||||
|
|
@ -132,6 +138,8 @@ export class KanbanLayoutEngine {
|
|||
x: colX, // pill center = task.x = colX
|
||||
y: baseY,
|
||||
color: config.color,
|
||||
overflowCount: overflow,
|
||||
overflowY: baseY + headerHeight + visibleCount * rowHeight,
|
||||
});
|
||||
|
||||
// Position tasks below header
|
||||
|
|
|
|||
|
|
@ -90,10 +90,22 @@ export interface GraphNode {
|
|||
reviewState?: 'none' | 'review' | 'needsFix' | 'approved';
|
||||
/** Requires clarification indicator */
|
||||
needsClarification?: 'lead' | 'user' | null;
|
||||
/** Task is blocked by other tasks */
|
||||
isBlocked?: boolean;
|
||||
/** Display IDs of tasks blocking this one */
|
||||
blockedByDisplayIds?: string[];
|
||||
/** Display IDs of tasks this one blocks */
|
||||
blocksDisplayIds?: string[];
|
||||
|
||||
// ─── Process-specific ──────────────────────────────────────────────────
|
||||
/** Clickable URL for process */
|
||||
processUrl?: string;
|
||||
/** Who registered the process */
|
||||
processRegisteredBy?: string;
|
||||
/** Command used to start the process */
|
||||
processCommand?: string;
|
||||
/** When the process was registered (ISO) */
|
||||
processRegisteredAt?: string;
|
||||
|
||||
// ─── Force simulation (managed by the package internally) ──────────────
|
||||
x?: number;
|
||||
|
|
|
|||
|
|
@ -201,9 +201,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
}
|
||||
drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges);
|
||||
|
||||
// 2b. Particles (cap at 50 for performance)
|
||||
const cappedParticles = state.particles.length > 50
|
||||
? state.particles.slice(-50)
|
||||
// 2b. Particles (cap at 100 for performance)
|
||||
const cappedParticles = state.particles.length > 100
|
||||
? state.particles.slice(-100)
|
||||
: state.particles;
|
||||
drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time);
|
||||
|
||||
|
|
|
|||
|
|
@ -6457,6 +6457,28 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Also attempt control_response via stdin — the lead runtime MAY forward it
|
||||
// to the teammate subprocess. This was broken before (missing updatedInput: {})
|
||||
// but is now fixed. Belt-and-suspenders: settings handle future calls,
|
||||
// control_response may unblock the CURRENT waiting prompt.
|
||||
if (allow && run.child?.stdin?.writable) {
|
||||
const controlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: { behavior: 'allow', updatedInput: {} },
|
||||
},
|
||||
};
|
||||
run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => {
|
||||
if (err) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ export class TeamGraphAdapter {
|
|||
: undefined,
|
||||
recentTools: (toolHistory?.[leadName] ?? [])
|
||||
.filter((tool) => tool.state !== 'running' && !!tool.finishedAt)
|
||||
.slice(0, 3)
|
||||
.slice(0, 5)
|
||||
.map((tool) => ({
|
||||
name: tool.toolName,
|
||||
preview: tool.preview,
|
||||
|
|
@ -346,7 +346,7 @@ export class TeamGraphAdapter {
|
|||
: undefined,
|
||||
recentTools: (toolHistory?.[member.name] ?? [])
|
||||
.filter((tool) => tool.state !== 'running' && !!tool.finishedAt)
|
||||
.slice(0, 3)
|
||||
.slice(0, 5)
|
||||
.map((tool) => ({
|
||||
name: tool.toolName,
|
||||
preview: tool.preview,
|
||||
|
|
@ -369,11 +369,32 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
#buildTaskNodes(nodes: GraphNode[], edges: GraphEdge[], data: TeamData, teamName: string): void {
|
||||
// Build lookup tables for fast resolution
|
||||
const completedTaskIds = new Set<string>();
|
||||
const taskDisplayIds = new Map<string, string>();
|
||||
for (const t of data.tasks) {
|
||||
if (t.status === 'completed' || t.status === 'deleted') completedTaskIds.add(t.id);
|
||||
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
||||
}
|
||||
|
||||
for (const task of data.tasks) {
|
||||
if (task.status === 'deleted') continue;
|
||||
const taskId = `task:${teamName}:${task.id}`;
|
||||
const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null;
|
||||
|
||||
// Task is blocked if any blockedBy task is still not completed
|
||||
const isBlocked =
|
||||
(task.blockedBy?.length ?? 0) > 0 &&
|
||||
task.blockedBy!.some((id) => !completedTaskIds.has(id));
|
||||
|
||||
// Resolve display IDs for dependencies
|
||||
const blockedByDisplayIds = task.blockedBy?.length
|
||||
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||
: undefined;
|
||||
const blocksDisplayIds = task.blocks?.length
|
||||
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||
: undefined;
|
||||
|
||||
nodes.push({
|
||||
id: taskId,
|
||||
kind: 'task',
|
||||
|
|
@ -385,6 +406,9 @@ export class TeamGraphAdapter {
|
|||
displayId: task.displayId ?? undefined,
|
||||
ownerId: ownerMemberId,
|
||||
needsClarification: task.needsClarification ?? null,
|
||||
isBlocked,
|
||||
blockedByDisplayIds,
|
||||
blocksDisplayIds,
|
||||
domainRef: { kind: 'task', teamName, taskId: task.id },
|
||||
});
|
||||
|
||||
|
|
@ -453,6 +477,9 @@ export class TeamGraphAdapter {
|
|||
label: proc.label,
|
||||
state: 'active',
|
||||
processUrl: proc.url ?? undefined,
|
||||
processRegisteredBy: proc.registeredBy ?? undefined,
|
||||
processCommand: proc.command ?? undefined,
|
||||
processRegisteredAt: proc.registeredAt,
|
||||
domainRef: { kind: 'process', teamName, processId: proc.id },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||
import { Ban, ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
|
||||
|
|
@ -78,8 +78,23 @@ export const GraphNodePopover = ({
|
|||
|
||||
// Process
|
||||
return (
|
||||
<div className="min-w-[180px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
||||
<div className="min-w-[180px] max-w-[260px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
||||
<div className="font-mono text-xs font-bold text-[var(--color-text)]">{node.label}</div>
|
||||
{node.processCommand && (
|
||||
<div className="mt-1 truncate font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
$ {node.processCommand}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 space-y-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{node.processRegisteredBy && (
|
||||
<div>
|
||||
Started by: <span className="text-[var(--color-text)]">{node.processRegisteredBy}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.processRegisteredAt && (
|
||||
<div>At: {new Date(node.processRegisteredAt).toLocaleTimeString()}</div>
|
||||
)}
|
||||
</div>
|
||||
{node.processUrl && (
|
||||
<a
|
||||
href={node.processUrl}
|
||||
|
|
@ -254,7 +269,7 @@ const MemberPopoverContent = ({
|
|||
Recent tools
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{node.recentTools.slice(0, 3).map((tool) => {
|
||||
{node.recentTools.slice(0, 5).map((tool) => {
|
||||
const shortName = formatToolName(tool.name);
|
||||
const shortPreview = formatToolPreview(tool.preview);
|
||||
return (
|
||||
|
|
@ -368,6 +383,14 @@ const TaskPopoverContent = ({
|
|||
{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"
|
||||
|
|
@ -378,6 +401,18 @@ const TaskPopoverContent = ({
|
|||
)}
|
||||
</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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue