fix(team): stable context % + show "% of context" + graph improvements

Context window fix:
- Derive initial contextWindow from model selection (haiku=200K, else=1M)
- Use limitContext flag from request
- Updated to exact value from modelUsage on result.success
- Formula: input_tokens + cache_creation + cache_read (all three needed)

TokenUsageDisplay:
- New contextWindowSize prop → shows "X% of context" (not "X% of input")

Graph improvements:
- Pending approval: pulsing amber ring on member nodes
- Working spinner: spinning arc when member has active task
- Current task indicator in popover (Loader2 + "working on" + task name)
- GraphNode: added currentTaskId, currentTaskSubject, pendingApproval, activeTool fields
- Adapter: passes pendingApprovalAgents + currentTaskId from store
This commit is contained in:
iliya 2026-03-28 18:32:10 +02:00
parent 11506c6ea8
commit f286468dac
10 changed files with 917 additions and 39 deletions

View file

@ -82,6 +82,10 @@ export function drawAgents(
ctx.stroke();
}
if (node.activeTool) {
drawToolCard(ctx, x, y, r, node.activeTool, time);
}
// Name + role label (single line: "jack · developer")
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
drawLabel(ctx, x, y, r, labelText, color);
@ -147,7 +151,7 @@ function drawHexBody(
ctx.fill();
// Scanline effect
const scanSpeed = state === 'active' || state === 'thinking'
const scanSpeed = state === 'active' || state === 'thinking' || state === 'tool_calling'
? ANIM.scanline.active
: ANIM.scanline.normal;
const scanY = ((time * scanSpeed) % (r * 2)) - r;
@ -174,6 +178,87 @@ function drawHexBody(
ctx.stroke();
}
function truncateCardText(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string {
if (ctx.measureText(text).width <= maxWidth) return text;
let out = text;
while (out.length > 1 && ctx.measureText(`${out}...`).width > maxWidth) {
out = out.slice(0, -1);
}
return `${out}...`;
}
function drawToolCard(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
tool: NonNullable<GraphNode['activeTool']>,
time: number,
): void {
const labelBase = tool.preview ? `${tool.name}: ${tool.preview}` : tool.name;
const labelText =
tool.state === 'error'
? `${tool.name}: failed`
: tool.state === 'complete' && tool.resultPreview
? `${tool.name}: ${tool.resultPreview}`
: labelBase;
ctx.save();
ctx.font = '8px monospace';
const truncated = truncateCardText(ctx, labelText, 104);
const textWidth = ctx.measureText(truncated).width;
const cardW = Math.max(62, Math.min(124, textWidth + 24));
const cardH = 18;
const cardX = x - cardW / 2;
const cardY = y - r - cardH - 10;
const accent =
tool.state === 'error'
? COLORS.error
: tool.state === 'complete'
? COLORS.complete
: COLORS.tool_calling;
ctx.beginPath();
ctx.roundRect(cardX, cardY, cardW, cardH, 4);
ctx.fillStyle = tool.state === 'running' ? 'rgba(10, 15, 30, 0.85)' : 'rgba(10, 15, 30, 0.78)';
ctx.fill();
ctx.strokeStyle = hexWithAlpha(accent, 0.7);
ctx.lineWidth = 1;
ctx.stroke();
const indicatorX = cardX + 10;
const indicatorY = cardY + cardH / 2;
if (tool.state === 'running') {
ctx.beginPath();
ctx.arc(
indicatorX,
indicatorY,
4.5,
time * 3,
time * 3 + Math.PI * 1.2,
);
ctx.strokeStyle = accent;
ctx.lineWidth = 1.4;
ctx.stroke();
} else {
ctx.beginPath();
ctx.arc(indicatorX, indicatorY, 2.5, 0, Math.PI * 2);
ctx.fillStyle = accent;
ctx.fill();
}
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = accent;
ctx.fillText(truncated, indicatorX + 8, indicatorY);
ctx.restore();
}
function drawBreathing(
ctx: CanvasRenderingContext2D,
x: number,

View file

@ -55,6 +55,26 @@ export interface GraphNode {
currentTaskSubject?: string;
/** Agent is awaiting tool approval from the user */
pendingApproval?: boolean;
/** Currently running or just-finished tool activity shown near the node */
activeTool?: {
name: string;
preview?: string;
state: 'running' | 'complete' | 'error';
startedAt: string;
finishedAt?: string;
resultPreview?: string;
source: 'runtime' | 'inbox';
};
/** Recent completed tool activity for popovers and secondary UI */
recentTools?: Array<{
name: string;
preview?: string;
state: 'complete' | 'error';
startedAt: string;
finishedAt: string;
resultPreview?: string;
source: 'runtime' | 'inbox';
}>;
// ─── Task-specific ─────────────────────────────────────────────────────
/** Short display ID (e.g., "#3") */
@ -119,7 +139,7 @@ export interface GraphParticle {
// ─── Domain Reference (opaque back-pointer) ──────────────────────────────────
export type GraphDomainRef =
| { kind: 'lead'; teamName: string }
| { kind: 'lead'; teamName: string; memberName: string }
| { kind: 'member'; teamName: string; memberName: string }
| { kind: 'task'; teamName: string; taskId: string }
| { kind: 'process'; teamName: string; processId: string };

View file

@ -45,7 +45,11 @@ import {
type ParsedTeammateContent,
} from '@shared/utils/teammateMessageParser';
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import {
extractToolPreview,
extractToolResultPreview,
formatToolSummaryFromCalls,
} from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { type ChildProcess, type spawn } from 'child_process';
import { randomUUID } from 'crypto';
@ -80,6 +84,7 @@ function killTeamProcess(child: ChildProcess | null | undefined): void {
}
import type {
ActiveToolCall,
CrossTeamSendResult,
InboxMessage,
LeadContextUsage,
@ -95,6 +100,7 @@ import type {
TeamProvisioningState,
TeamRuntimeState,
TeamTask,
ToolActivityEventPayload,
ToolApprovalAutoResolved,
ToolApprovalEvent,
ToolApprovalRequest,
@ -275,6 +281,8 @@ interface ProvisioningRun {
leadMsgSeq: number;
/** Accumulated tool_use details between text messages. */
pendingToolCalls: ToolCallMeta[];
/** Active runtime tool calls keyed by tool_use_id. */
activeToolCalls: Map<string, ActiveToolCall>;
/** True when a direct MCP cross_team_send happened and sentMessages history should refresh. */
pendingDirectCrossTeamSendRefresh: boolean;
/** Throttle timestamp for emitting inbox refresh events for lead text. */
@ -1673,6 +1681,18 @@ export class TeamProvisioningService {
return text.length > 0 ? text : null;
}
private extractStreamContentBlocks(msg: Record<string, unknown>): Record<string, unknown>[] {
const topLevelContent = msg.content;
if (Array.isArray(topLevelContent)) {
return topLevelContent as Record<string, unknown>[];
}
const message = msg.message;
if (!message || typeof message !== 'object') return [];
const innerContent = (message as Record<string, unknown>).content;
return Array.isArray(innerContent) ? (innerContent as Record<string, unknown>[]) : [];
}
private async matchCrossTeamLeadInboxMessages(
teamName: string,
leadName: string,
@ -2089,6 +2109,94 @@ export class TeamProvisioningService {
});
}
private emitToolActivity(run: ProvisioningRun, payload: ToolActivityEventPayload): void {
if (!this.isCurrentTrackedRun(run)) return;
this.teamChangeEmitter?.({
type: 'tool-activity',
teamName: run.teamName,
runId: run.runId,
detail: JSON.stringify(payload),
});
}
private startRuntimeToolActivity(
run: ProvisioningRun,
memberName: string,
block: Record<string, unknown>
): void {
const rawId = typeof block.id === 'string' ? block.id.trim() : '';
if (!rawId) return;
const toolUseId = rawId;
if (run.activeToolCalls.has(toolUseId)) return;
const toolName = typeof block.name === 'string' ? block.name : 'unknown';
const input = (block.input ?? {}) as Record<string, unknown>;
const activity: ActiveToolCall = {
memberName,
toolUseId,
toolName,
preview: extractToolPreview(toolName, input),
startedAt: nowIso(),
state: 'running',
source: 'runtime',
};
run.activeToolCalls.set(toolUseId, activity);
this.emitToolActivity(run, {
action: 'start',
activity: {
memberName: activity.memberName,
toolUseId: activity.toolUseId,
toolName: activity.toolName,
preview: activity.preview,
startedAt: activity.startedAt,
source: activity.source,
},
});
}
private finishRuntimeToolActivity(
run: ProvisioningRun,
toolUseId: string,
resultContent: unknown,
isError: boolean
): void {
const active = run.activeToolCalls.get(toolUseId);
if (!active) return;
run.activeToolCalls.delete(toolUseId);
this.emitToolActivity(run, {
action: 'finish',
memberName: active.memberName,
toolUseId,
finishedAt: nowIso(),
resultPreview: extractToolResultPreview(resultContent),
isError,
});
}
private resetRuntimeToolActivity(run: ProvisioningRun, memberName?: string): void {
if (run.activeToolCalls.size === 0) return;
if (!memberName) {
run.activeToolCalls.clear();
this.emitToolActivity(run, { action: 'reset' });
return;
}
let removed = false;
for (const [toolUseId, active] of run.activeToolCalls.entries()) {
if (active.memberName !== memberName) continue;
run.activeToolCalls.delete(toolUseId);
removed = true;
}
if (removed) {
this.emitToolActivity(run, { action: 'reset', memberName });
}
}
/**
* Update spawn status for a specific team member and emit a change event.
*/
@ -2951,6 +3059,7 @@ export class TeamProvisioningService {
activeCrossTeamReplyHints: [],
leadMsgSeq: 0,
pendingToolCalls: [],
activeToolCalls: new Map(),
pendingDirectCrossTeamSendRefresh: false,
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
@ -3384,6 +3493,7 @@ export class TeamProvisioningService {
activeCrossTeamReplyHints: [],
leadMsgSeq: 0,
pendingToolCalls: [],
activeToolCalls: new Map(),
pendingDirectCrossTeamSendRefresh: false,
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
@ -4955,6 +5065,7 @@ export class TeamProvisioningService {
// The permission_request may arrive as plain JSON without <teammate-message> wrapper,
// and handleNativeTeammateUserMessage only processes <teammate-message> blocks.
const rawUserText = this.extractStreamUserText(msg);
const content = this.extractStreamContentBlocks(msg);
if (rawUserText) {
const perm = parsePermissionRequest(rawUserText);
if (perm) {
@ -4969,20 +5080,22 @@ export class TeamProvisioningService {
);
}
}
for (const block of content) {
if (block?.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue;
this.finishRuntimeToolActivity(
run,
block.tool_use_id,
block.content,
block.is_error === true
);
}
this.handleNativeTeammateUserMessage(run, msg);
return;
}
if (msg.type === 'assistant') {
const content = Array.isArray(msg.content)
? (msg.content as Record<string, unknown>[])
: (() => {
const message = msg.message;
if (!message || typeof message !== 'object') return null;
const inner = (message as Record<string, unknown>).content;
return Array.isArray(inner) ? (inner as Record<string, unknown>[]) : null;
})();
const content = this.extractStreamContentBlocks(msg);
const hasCapturedSendMessage = (content ?? []).some((part) => {
const hasCapturedSendMessage = content.some((part) => {
if (!part || typeof part !== 'object') return false;
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
const input = part.input;
@ -4991,7 +5104,7 @@ export class TeamProvisioningService {
return typeof recipient === 'string' && recipient.trim().length > 0;
});
const textParts = (content ?? [])
const textParts = content
.filter((part) => part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text as string);
if (textParts.length > 0) {
@ -5059,7 +5172,7 @@ export class TeamProvisioningService {
// Accumulate tool_use details from tool-only messages (text + tool_use are separate in stream-json).
// These details will be attached to the next text message as toolCalls/toolSummary.
// Works in both pre-ready and post-ready phases so early live messages get tool metadata.
for (const block of content ?? []) {
for (const block of content) {
if (
block?.type === 'tool_use' &&
typeof block.name === 'string' &&
@ -5069,19 +5182,21 @@ export class TeamProvisioningService {
run.pendingToolCalls.push({
name: block.name,
preview: extractToolPreview(block.name, input),
toolUseId: typeof block.id === 'string' ? block.id : undefined,
});
this.startRuntimeToolActivity(run, this.getRunLeadName(run), block);
}
}
// Track member spawn events from Task tool_use blocks with team_name.
// When the lead calls Task(team_name=X, name=Y), it means member Y is being spawned.
this.captureTeamSpawnEvents(run, content ?? []);
this.captureTeamSpawnEvents(run, content);
// Capture SendMessage tool_use blocks from assistant output.
// Works in both pre-ready and post-ready phases so outbound runtime messages
// are visible in our team message artifacts even if Claude's own routing drifts.
if (!run.silentUserDmForward || run.silentUserDmForward.mode === 'member_inbox_relay') {
this.captureSendMessages(run, content ?? []);
this.captureSendMessages(run, content);
}
// Extract context window usage from message.usage for real-time tracking.
@ -5100,12 +5215,24 @@ export class TeamProvisioningService {
: 0;
const cacheRead =
typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0;
// Total context window usage = all three token categories
// input_tokens = tokens AFTER last cache breakpoint (small)
// cache_creation = tokens written to cache (first request)
// cache_read = tokens read from cache (subsequent requests) — these ARE in context window
const currentTokens = inputTokens + cacheCreation + cacheRead;
if (!run.leadContextUsage) {
// Determine initial context window from model selection
// computeEffectiveTeamModel() defaults to 'opus[1m]' when no model selected
const modelStr = (run.request.model ?? '').toLowerCase();
const isHaiku = modelStr.includes('haiku');
const isLimitedContext = run.request.limitContext === true;
// limitContext=true → 200K, haiku → 200K, [1m] → 1M, default → 1M (opus[1m])
const initialContextWindow = isLimitedContext || isHaiku ? 200_000 : 1_000_000;
run.leadContextUsage = {
currentTokens,
contextWindow: 200_000,
contextWindow: initialContextWindow,
lastUsageMessageId: msgId,
lastEmittedAt: 0,
};
@ -5225,6 +5352,7 @@ export class TeamProvisioningService {
);
}
this.resetRuntimeToolActivity(run, this.getRunLeadName(run));
this.setLeadActivity(run, 'idle');
}
if (run.pendingDirectCrossTeamSendRefresh) {
@ -5311,6 +5439,7 @@ export class TeamProvisioningService {
`[${run.teamName}] post-compact reminder ${wasInFlight ? 'turn errored' : 'pending dropped'} — clearing (strict policy)`
);
}
this.resetRuntimeToolActivity(run, this.getRunLeadName(run));
this.setLeadActivity(run, 'idle');
}
}
@ -5566,6 +5695,7 @@ export class TeamProvisioningService {
} catch (error) {
// Strict drop-after-attempt — do not re-arm.
clearPostCompactReminderState(run);
this.resetRuntimeToolActivity(run, this.getRunLeadName(run));
this.setLeadActivity(run, 'idle');
logger.warn(
`[${run.teamName}] post-compact reminder injection failed: ${
@ -6233,6 +6363,7 @@ export class TeamProvisioningService {
}
run.provisioningComplete = true;
this.resetRuntimeToolActivity(run, this.getRunLeadName(run));
this.setLeadActivity(run, 'idle');
// Clear provisioning timeout — no longer needed
@ -6697,6 +6828,7 @@ export class TeamProvisioningService {
* Remove a run from tracking maps.
*/
private cleanupRun(run: ProvisioningRun): void {
this.resetRuntimeToolActivity(run);
this.setLeadActivity(run, 'offline');
run.pendingDirectCrossTeamSendRefresh = false;
if (run.timeoutHandle) {

View file

@ -48,6 +48,8 @@ interface TokenUsageDisplayProps {
totalPhases?: number;
/** Optional USD cost for this usage */
costUsd?: number;
/** Context window size (e.g., 200000 or 1000000). When provided, shows "X% context used" instead of "X% of input". */
contextWindowSize?: number;
}
/**
@ -57,9 +59,11 @@ interface TokenUsageDisplayProps {
const SessionContextSection = ({
contextStats,
totalInputTokens,
contextWindowSize,
}: Readonly<{
contextStats: ContextStats;
totalInputTokens: number;
contextWindowSize?: number;
}>): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
@ -67,11 +71,15 @@ const SessionContextSection = ({
// contextStats.totalEstimatedTokens already includes all categories (CLAUDE.md, @files,
// tool outputs, thinking+text, task coordination, user messages) — no manual adjustment needed.
// Denominator is total input tokens only (not output), since visible context is part of input.
// Show context window usage % when contextWindowSize is available (more useful),
// otherwise fall back to visible context / total input ratio.
const contextPercent =
totalInputTokens > 0
? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
contextWindowSize && contextWindowSize > 0
? Math.min((totalInputTokens / contextWindowSize) * 100, 100).toFixed(1)
: totalInputTokens > 0
? Math.min((contextStats.totalEstimatedTokens / totalInputTokens) * 100, 100).toFixed(1)
: '0.0';
const contextLabel = contextWindowSize ? 'of context' : 'of input';
// Count accumulated injections by category
const claudeMdCount = contextStats.accumulatedInjections.filter(
@ -144,7 +152,7 @@ const SessionContextSection = ({
className="whitespace-nowrap text-[10px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}%)
{formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% {contextLabel})
</span>
</div>
@ -253,6 +261,7 @@ export const TokenUsageDisplay = ({
phaseNumber,
totalPhases,
costUsd,
contextWindowSize,
}: Readonly<TokenUsageDisplayProps>): React.JSX.Element => {
const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
// Total input tokens only (without output) — used as denominator for visible context %
@ -531,6 +540,7 @@ export const TokenUsageDisplay = ({
<SessionContextSection
contextStats={contextStats}
totalInputTokens={totalInputTokens}
contextWindowSize={contextWindowSize}
/>
)}

View file

@ -17,7 +17,12 @@ import type {
GraphNodeState,
GraphParticle,
} from '@claude-teams/agent-graph';
import type { InboxMessage, MemberSpawnStatusEntry, TeamData } from '@shared/types/team';
import type {
ActiveToolCall,
InboxMessage,
MemberSpawnStatusEntry,
TeamData,
} from '@shared/types/team';
import type { LeadContextUsage } from '@shared/types/team';
export class TeamGraphAdapter {
@ -51,7 +56,10 @@ export class TeamGraphAdapter {
teamName: string,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
leadContext?: LeadContextUsage,
pendingApprovalAgents?: Set<string>
pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
toolHistory?: Record<string, ActiveToolCall[]>
): GraphDataPort {
if (teamData?.teamName !== teamName) {
return TeamGraphAdapter.#emptyResult(teamName);
@ -62,7 +70,44 @@ export class TeamGraphAdapter {
const approvalKey = pendingApprovalAgents?.size
? Array.from(pendingApprovalAgents).sort().join(',')
: '';
const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}`;
const activeToolKey = activeTools
? Object.entries(activeTools)
.flatMap(([memberName, tools]) =>
Object.values(tools).map(
(tool) =>
`${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
)
)
.sort()
.join('|')
: '';
const finishedVisibleKey = finishedVisible
? Object.entries(finishedVisible)
.flatMap(([memberName, tools]) =>
Object.values(tools).map(
(tool) =>
`${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
)
)
.sort()
.join('|')
: '';
const historyKey = toolHistory
? Object.entries(toolHistory)
.map(
([memberName, tools]) =>
`${memberName}:${tools
.slice(0, 3)
.map(
(tool) =>
`${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
)
.join(',')}`
)
.sort()
.join('|')
: '';
const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}`;
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
return this.#cachedResult;
}
@ -84,8 +129,19 @@ export class TeamGraphAdapter {
const particles: GraphParticle[] = [];
const leadId = `lead:${teamName}`;
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
this.#buildLeadNode(nodes, leadId, teamData, teamName, leadContext);
this.#buildLeadNode(
nodes,
leadId,
teamData,
teamName,
leadName,
leadContext,
activeTools,
finishedVisible,
toolHistory
);
this.#buildMemberNodes(
nodes,
edges,
@ -93,7 +149,10 @@ export class TeamGraphAdapter {
teamData,
teamName,
spawnStatuses,
pendingApprovalAgents
pendingApprovalAgents,
activeTools,
finishedVisible,
toolHistory
);
this.#buildTaskNodes(nodes, edges, teamData, teamName);
this.#buildProcessNodes(nodes, edges, teamData, teamName);
@ -126,23 +185,75 @@ export class TeamGraphAdapter {
// ─── Private: node builders ──────────────────────────────────────────────
static #getLeadMemberName(data: TeamData, teamName: string): string {
return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`;
}
static #selectVisibleTool(
runningTools?: Record<string, ActiveToolCall>,
finishedTools?: Record<string, ActiveToolCall>
): ActiveToolCall | undefined {
const newestRunning = Object.values(runningTools ?? {}).sort((a, b) =>
b.startedAt.localeCompare(a.startedAt)
)[0];
if (newestRunning) return newestRunning;
return Object.values(finishedTools ?? {}).sort((a, b) =>
(b.finishedAt ?? '').localeCompare(a.finishedAt ?? '')
)[0];
}
#buildLeadNode(
nodes: GraphNode[],
leadId: string,
data: TeamData,
teamName: string,
leadContext?: LeadContextUsage
leadName: string,
leadContext?: LeadContextUsage,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
toolHistory?: Record<string, ActiveToolCall[]>
): void {
const percent = leadContext?.percent;
const activeTool = TeamGraphAdapter.#selectVisibleTool(
activeTools?.[leadName],
finishedVisible?.[leadName]
);
nodes.push({
id: leadId,
kind: 'lead',
label: data.config.name || teamName,
state: data.isAlive ? 'active' : 'idle',
state: !data.isAlive
? 'idle'
: Object.keys(activeTools?.[leadName] ?? {}).length > 0
? 'tool_calling'
: 'active',
color: data.config.color ?? undefined,
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: agentAvatarUrl('team-lead', 64),
domainRef: { kind: 'lead', teamName },
avatarUrl: agentAvatarUrl(leadName, 64),
activeTool: activeTool
? {
name: activeTool.toolName,
preview: activeTool.preview,
state: activeTool.state,
startedAt: activeTool.startedAt,
finishedAt: activeTool.finishedAt,
resultPreview: activeTool.resultPreview,
source: activeTool.source,
}
: undefined,
recentTools: (toolHistory?.[leadName] ?? [])
.filter((tool) => tool.state !== 'running' && !!tool.finishedAt)
.slice(0, 3)
.map((tool) => ({
name: tool.toolName,
preview: tool.preview,
state: tool.state === 'error' ? 'error' : 'complete',
startedAt: tool.startedAt,
finishedAt: tool.finishedAt!,
resultPreview: tool.resultPreview,
source: tool.source,
})),
domainRef: { kind: 'lead', teamName, memberName: leadName },
});
}
@ -153,7 +264,10 @@ export class TeamGraphAdapter {
data: TeamData,
teamName: string,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
pendingApprovalAgents?: Set<string>
pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
toolHistory?: Record<string, ActiveToolCall[]>
): void {
for (const member of data.members) {
if (member.removedAt) continue;
@ -161,12 +275,19 @@ export class TeamGraphAdapter {
const memberId = `member:${teamName}:${member.name}`;
const spawn = spawnStatuses?.[member.name];
const activeTool = TeamGraphAdapter.#selectVisibleTool(
activeTools?.[member.name],
finishedVisible?.[member.name]
);
const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0;
nodes.push({
id: memberId,
kind: 'member',
label: member.name,
state: TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status),
state: hasRunningTool
? 'tool_calling'
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status),
color: member.color ?? undefined,
role: member.role ?? undefined,
spawnStatus: spawn?.status,
@ -176,6 +297,29 @@ export class TeamGraphAdapter {
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
: undefined,
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
activeTool: activeTool
? {
name: activeTool.toolName,
preview: activeTool.preview,
state: activeTool.state,
startedAt: activeTool.startedAt,
finishedAt: activeTool.finishedAt,
resultPreview: activeTool.resultPreview,
source: activeTool.source,
}
: undefined,
recentTools: (toolHistory?.[member.name] ?? [])
.filter((tool) => tool.state !== 'running' && !!tool.finishedAt)
.slice(0, 3)
.map((tool) => ({
name: tool.toolName,
preview: tool.preview,
state: tool.state === 'error' ? 'error' : 'complete',
startedAt: tool.startedAt,
finishedAt: tool.finishedAt!,
resultPreview: tool.resultPreview,
source: tool.source,
})),
domainRef: { kind: 'member', teamName, memberName: member.name },
});

View file

@ -15,12 +15,23 @@ import type { GraphDataPort } from '@claude-teams/agent-graph';
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
const { teamData, spawnStatuses, leadContext, pendingApprovals } = useStore(
const {
teamData,
spawnStatuses,
leadContext,
pendingApprovals,
activeTools,
finishedVisible,
toolHistory,
} = useStore(
useShallow((s) => ({
teamData: s.selectedTeamData,
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
pendingApprovals: s.pendingApprovals,
activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined,
finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined,
toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined,
}))
);
@ -39,8 +50,20 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
teamName,
spawnStatuses,
leadContext,
pendingApprovalAgents
pendingApprovalAgents,
activeTools,
finishedVisible,
toolHistory
),
[teamData, teamName, spawnStatuses, leadContext, pendingApprovalAgents]
[
teamData,
teamName,
spawnStatuses,
leadContext,
pendingApprovalAgents,
activeTools,
finishedVisible,
toolHistory,
]
);
}

View file

@ -80,7 +80,10 @@ function MemberPopoverContent({
onCreateTask?: (owner: string) => void;
onOpenTask?: (taskId: string) => void;
}): React.JSX.Element {
const memberName = node.domainRef.kind === 'member' ? node.domainRef.memberName : 'team-lead';
const memberName =
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
? node.domainRef.memberName
: 'team-lead';
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
const statusLabel =
node.state === 'active'
@ -89,7 +92,9 @@ function MemberPopoverContent({
? 'Idle'
: node.state === 'terminated'
? 'Offline'
: node.state;
: node.state === 'tool_calling'
? 'Running tool'
: node.state;
const statusDotColor =
node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling'
@ -199,6 +204,67 @@ function MemberPopoverContent({
</div>
)}
{node.activeTool && (
<div className="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-secondary)] px-2 py-1.5 text-[10px]">
<div className="flex items-center gap-1.5">
<Loader2
className={`size-3 shrink-0 ${node.activeTool.state === 'running' ? 'animate-spin' : ''}`}
style={{
color:
node.activeTool.state === 'error'
? '#ef4444'
: node.activeTool.state === 'complete'
? '#22c55e'
: (node.color ?? '#66ccff'),
}}
/>
<span className="font-medium text-[var(--color-text)]">
{node.activeTool.state === 'running'
? 'Running tool'
: node.activeTool.state === 'error'
? 'Tool failed'
: 'Tool finished'}
</span>
</div>
<div className="mt-1 font-mono text-[var(--color-text-muted)]">
{node.activeTool.preview
? `${node.activeTool.name}: ${node.activeTool.preview}`
: node.activeTool.name}
</div>
{node.activeTool.resultPreview && node.activeTool.state !== 'running' && (
<div className="mt-1 text-[var(--color-text-muted)]">
{node.activeTool.resultPreview}
</div>
)}
</div>
)}
{node.recentTools && node.recentTools.length > 0 && (
<div className="mt-2">
<div className="mb-1 text-[10px] font-medium text-[var(--color-text-muted)]">
Recent tools
</div>
<div className="space-y-1">
{node.recentTools.slice(0, 3).map((tool) => (
<div
key={`${tool.name}:${tool.finishedAt}:${tool.startedAt}`}
className="rounded border border-[var(--color-border)] bg-[var(--color-surface-secondary)] px-2 py-1 text-[10px]"
>
<div
className="font-mono"
style={{ color: tool.state === 'error' ? '#ef4444' : '#22c55e' }}
>
{tool.preview ? `${tool.name}: ${tool.preview}` : tool.name}
</div>
{tool.resultPreview && (
<div className="mt-0.5 text-[var(--color-text-muted)]">{tool.resultPreview}</div>
)}
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="mt-3 flex flex-wrap gap-1.5">
<Button

View file

@ -37,10 +37,12 @@ import { createUpdateSlice } from './slices/updateSlice';
import type { DetectedError } from '../types/data';
import type { AppState } from './types';
import type {
ActiveToolCall,
CliInstallerProgress,
LeadContextUsage,
ScheduleChangeEvent,
TeamChangeEvent,
ToolActivityEventPayload,
ToolApprovalEvent,
ToolApprovalRequest,
UpdaterStatus,
@ -48,6 +50,8 @@ import type {
const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
const FINISHED_TOOL_DISPLAY_MS = 1_500;
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
// =============================================================================
// Store Creation
@ -155,6 +159,7 @@ export function initializeNotificationListeners(): () => void {
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let inProgressChangePresencePollInFlight = false;
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
@ -166,6 +171,112 @@ export function initializeNotificationListeners(): () => void {
const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
const buildToolActivityTimerKey = (
teamName: string,
memberName: string,
toolUseId: string,
kind: 'fade'
): string => `${teamName}:${memberName}:${toolUseId}:${kind}`;
const clearToolActivityTimer = (
teamName: string,
memberName: string,
toolUseId: string,
kind: 'fade'
): void => {
const key = buildToolActivityTimerKey(teamName, memberName, toolUseId, kind);
const existing = toolActivityTimers.get(key);
if (existing) {
clearTimeout(existing);
toolActivityTimers.delete(key);
}
};
const scheduleToolActivityTimer = (
teamName: string,
memberName: string,
toolUseId: string,
kind: 'fade',
delayMs: number,
cb: () => void
): void => {
clearToolActivityTimer(teamName, memberName, toolUseId, kind);
const key = buildToolActivityTimerKey(teamName, memberName, toolUseId, kind);
const timer = setTimeout(() => {
toolActivityTimers.delete(key);
cb();
}, delayMs);
toolActivityTimers.set(key, timer);
};
const clearToolActivityTimersForTeam = (teamName: string): void => {
for (const [key, timer] of toolActivityTimers.entries()) {
if (!key.startsWith(`${teamName}:`)) continue;
clearTimeout(timer);
toolActivityTimers.delete(key);
}
};
const clearRuntimeToolStateForTeam = (
prev: AppState,
teamName: string
): Pick<AppState, 'activeToolsByTeam' | 'finishedVisibleByTeam' | 'toolHistoryByTeam'> => {
const nextActive = { ...prev.activeToolsByTeam };
const nextFinished = { ...prev.finishedVisibleByTeam };
const nextHistory = { ...prev.toolHistoryByTeam };
delete nextActive[teamName];
delete nextFinished[teamName];
delete nextHistory[teamName];
return {
activeToolsByTeam: nextActive,
finishedVisibleByTeam: nextFinished,
toolHistoryByTeam: nextHistory,
};
};
const pushToolHistoryEntry = (
history: Record<string, Record<string, ActiveToolCall[]>>,
teamName: string,
entry: ActiveToolCall
): Record<string, Record<string, ActiveToolCall[]>> => {
const teamHistory = { ...(history[teamName] ?? {}) };
const existing = teamHistory[entry.memberName] ?? [];
teamHistory[entry.memberName] = [
entry,
...existing.filter((t) => t.toolUseId !== entry.toolUseId),
].slice(0, MAX_TOOL_HISTORY_PER_MEMBER);
return { ...history, [teamName]: teamHistory };
};
const upsertMemberToolEntry = (
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
entry: ActiveToolCall
): Record<string, Record<string, ActiveToolCall>> => ({
...(teamState ?? {}),
[entry.memberName]: {
...((teamState ?? {})[entry.memberName] ?? {}),
[entry.toolUseId]: entry,
},
});
const removeMemberToolEntry = (
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
memberName: string,
toolUseId: string
): Record<string, Record<string, ActiveToolCall>> => {
if (!teamState?.[memberName]?.[toolUseId]) return teamState ?? {};
const nextTeamState = { ...(teamState ?? {}) };
const nextMemberState = { ...(nextTeamState[memberName] ?? {}) };
delete nextMemberState[toolUseId];
if (Object.keys(nextMemberState).length === 0) {
delete nextTeamState[memberName];
} else {
nextTeamState[memberName] = nextMemberState;
}
return nextTeamState;
};
const removeMemberToolGroup = (
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
memberName: string
): Record<string, Record<string, ActiveToolCall>> => {
if (!teamState?.[memberName]) return teamState ?? {};
const nextTeamState = { ...(teamState ?? {}) };
delete nextTeamState[memberName];
return nextTeamState;
};
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
if (!projectId) return null;
const separatorIndex = projectId.indexOf('::');
@ -497,7 +608,11 @@ export function initializeNotificationListeners(): () => void {
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
const isIgnoredRuntimeRun = (() => {
if (!event.runId) return false;
return useStore.getState().ignoredProvisioningRunIds[event.runId] === event.teamName;
const state = useStore.getState();
return (
state.ignoredProvisioningRunIds[event.runId] === event.teamName ||
state.ignoredRuntimeRunIds[event.runId] === event.teamName
);
})();
if (isIgnoredRuntimeRun) {
return;
@ -518,6 +633,11 @@ export function initializeNotificationListeners(): () => void {
...prev.currentRuntimeRunIdByTeam,
[event.teamName]: event.runId ?? null,
},
ignoredRuntimeRunIds: Object.fromEntries(
Object.entries(prev.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== event.teamName
)
),
}));
}
};
@ -550,8 +670,16 @@ export function initializeNotificationListeners(): () => void {
if (nextActivity === 'offline') {
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
delete nextState.leadContextByTeam[event.teamName];
Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName));
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
nextState.ignoredRuntimeRunIds = event.runId
? {
...prev.ignoredRuntimeRunIds,
[event.runId]: event.teamName,
}
: prev.ignoredRuntimeRunIds;
clearToolActivityTimersForTeam(event.teamName);
}
return nextState as typeof prev;
@ -577,6 +705,128 @@ export function initializeNotificationListeners(): () => void {
return;
}
if (event.type === 'tool-activity' && event.detail) {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
try {
const payload = JSON.parse(event.detail) as ToolActivityEventPayload;
if (payload.action === 'start' && payload.activity) {
const activity: ActiveToolCall = {
memberName: payload.activity.memberName,
toolUseId: payload.activity.toolUseId,
toolName: payload.activity.toolName,
preview: payload.activity.preview,
startedAt: payload.activity.startedAt,
source: payload.activity.source,
state: 'running',
};
useStore.setState((prev) => ({
activeToolsByTeam: {
...prev.activeToolsByTeam,
[event.teamName]: upsertMemberToolEntry(
prev.activeToolsByTeam[event.teamName],
activity
),
},
}));
} else if (payload.action === 'finish' && payload.memberName && payload.toolUseId) {
const memberName = payload.memberName;
const toolUseId = payload.toolUseId;
useStore.setState((prev) => {
const current = prev.activeToolsByTeam[event.teamName]?.[memberName]?.[toolUseId];
if (!current) {
return {};
}
const completed: ActiveToolCall = {
...current,
state: payload.isError ? 'error' : 'complete',
finishedAt: payload.finishedAt ?? new Date().toISOString(),
resultPreview: payload.resultPreview,
};
scheduleToolActivityTimer(
event.teamName,
memberName,
toolUseId,
'fade',
FINISHED_TOOL_DISPLAY_MS,
() => {
useStore.setState((state) => {
const nextCurrent =
state.finishedVisibleByTeam[event.teamName]?.[memberName]?.[toolUseId];
if (!nextCurrent) {
return {};
}
return {
finishedVisibleByTeam: {
...state.finishedVisibleByTeam,
[event.teamName]: removeMemberToolEntry(
state.finishedVisibleByTeam[event.teamName],
memberName,
toolUseId
),
},
};
});
}
);
return {
activeToolsByTeam: {
...prev.activeToolsByTeam,
[event.teamName]: removeMemberToolEntry(
prev.activeToolsByTeam[event.teamName],
memberName,
toolUseId
),
},
finishedVisibleByTeam: {
...prev.finishedVisibleByTeam,
[event.teamName]: upsertMemberToolEntry(
prev.finishedVisibleByTeam[event.teamName],
completed
),
},
toolHistoryByTeam: pushToolHistoryEntry(
prev.toolHistoryByTeam,
event.teamName,
completed
),
};
});
} else if (payload.action === 'reset') {
if (payload.memberName) {
const memberName = payload.memberName;
useStore.setState((prev) => {
if (!prev.activeToolsByTeam[event.teamName]?.[memberName]) {
return {};
}
return {
activeToolsByTeam: {
...prev.activeToolsByTeam,
[event.teamName]: removeMemberToolGroup(
prev.activeToolsByTeam[event.teamName],
memberName
),
},
};
});
} else {
useStore.setState((prev) => ({
activeToolsByTeam: { ...prev.activeToolsByTeam, [event.teamName]: {} },
}));
}
}
} catch {
/* ignore malformed detail */
}
return;
}
// Member spawn status change: fetch updated spawn statuses for the team.
if (event.type === 'member-spawn') {
if (isStaleRuntimeEvent) {
@ -666,6 +916,8 @@ export function initializeNotificationListeners(): () => void {
teamRefreshTimers = new Map();
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
teamPresenceRefreshTimers = new Map();
for (const t of toolActivityTimers.values()) clearTimeout(t);
toolActivityTimers = new Map();
if (teamListRefreshTimer) {
clearTimeout(teamListRefreshTimer);
teamListRefreshTimer = null;

View file

@ -129,6 +129,7 @@ import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import type { AppState } from '../types';
import type { AppConfig } from '@renderer/types/data';
import type {
ActiveToolCall,
AddMemberRequest,
AddTaskCommentRequest,
CreateTaskRequest,
@ -559,6 +560,8 @@ export interface TeamSlice {
currentRuntimeRunIdByTeam: Record<string, string | null>;
/** Runs explicitly cleared after Unknown runId polling; late events/progress for them are ignored. */
ignoredProvisioningRunIds: Record<string, string>;
/** Runtime runs explicitly tombstoned after stop/offline so late events cannot resurrect UI state. */
ignoredRuntimeRunIds: Record<string, string>;
/**
* Per-team lower bound for provisioning progress timestamps.
* Used to ignore late progress events from a previous run after stoplaunch.
@ -566,6 +569,9 @@ export interface TeamSlice {
provisioningStartedAtFloorByTeam: Record<string, string>;
leadActivityByTeam: Record<string, LeadActivityState>;
leadContextByTeam: Record<string, LeadContextUsage>;
activeToolsByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
finishedVisibleByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
toolHistoryByTeam: Record<string, Record<string, ActiveToolCall[]>>;
/** Per-team per-member spawn statuses during team provisioning/launch. */
memberSpawnStatusesByTeam: Record<string, Record<string, MemberSpawnStatusEntry>>;
fetchMemberSpawnStatuses: (teamName: string) => Promise<void>;
@ -822,9 +828,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
currentProvisioningRunIdByTeam: {},
currentRuntimeRunIdByTeam: {},
ignoredProvisioningRunIds: {},
ignoredRuntimeRunIds: {},
provisioningStartedAtFloorByTeam: {},
leadActivityByTeam: {},
leadContextByTeam: {},
activeToolsByTeam: {},
finishedVisibleByTeam: {},
toolHistoryByTeam: {},
memberSpawnStatusesByTeam: {},
provisioningErrorByTeam: {},
clearProvisioningError: (teamName?: string) =>
@ -847,6 +857,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
try {
const snapshot = await api.teams.getMemberSpawnStatuses(teamName);
set((prev) => {
if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) {
return {};
}
if (
prev.currentRuntimeRunIdByTeam[teamName] == null &&
prev.leadActivityByTeam[teamName] === 'offline' &&
@ -871,6 +885,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...prev.currentRuntimeRunIdByTeam,
[teamName]: prev.currentRuntimeRunIdByTeam[teamName] ?? snapshot.runId,
},
ignoredRuntimeRunIds:
snapshot.runId == null
? prev.ignoredRuntimeRunIds
: Object.fromEntries(
Object.entries(prev.ignoredRuntimeRunIds).filter(
([, ignoredTeamName]) => ignoredTeamName !== teamName
)
),
memberSpawnStatusesByTeam: {
...prev.memberSpawnStatusesByTeam,
[teamName]: snapshot.statuses,
@ -1737,19 +1759,44 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
delete nextErrors[request.teamName];
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
delete nextSpawnStatuses[request.teamName];
const nextActiveTools = { ...state.activeToolsByTeam };
delete nextActiveTools[request.teamName];
const nextFinishedVisible = { ...state.finishedVisibleByTeam };
delete nextFinishedVisible[request.teamName];
const nextToolHistory = { ...state.toolHistoryByTeam };
delete nextToolHistory[request.teamName];
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName];
delete nextRuntimeRunIdByTeam[request.teamName];
const nextIgnoredRunIds = Object.fromEntries(
Object.entries(state.ignoredProvisioningRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
const nextIgnoredRuntimeRunIds = previousRuntimeRunId
? {
...Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
),
[previousRuntimeRunId]: request.teamName,
}
: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
return {
provisioningRuns: cleaned,
provisioningErrorByTeam: nextErrors,
memberSpawnStatusesByTeam: nextSpawnStatuses,
activeToolsByTeam: nextActiveTools,
finishedVisibleByTeam: nextFinishedVisible,
toolHistoryByTeam: nextToolHistory,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
ignoredProvisioningRunIds: nextIgnoredRunIds,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
};
});
@ -1833,6 +1880,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.currentRuntimeRunIdByTeam,
[request.teamName]: response.runId,
},
ignoredRuntimeRunIds: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
),
};
});
try {
@ -1894,19 +1946,44 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
delete nextErrors[request.teamName];
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
delete nextSpawnStatuses[request.teamName];
const nextActiveTools = { ...state.activeToolsByTeam };
delete nextActiveTools[request.teamName];
const nextFinishedVisible = { ...state.finishedVisibleByTeam };
delete nextFinishedVisible[request.teamName];
const nextToolHistory = { ...state.toolHistoryByTeam };
delete nextToolHistory[request.teamName];
const nextRuntimeRunIdByTeam = { ...state.currentRuntimeRunIdByTeam };
const previousRuntimeRunId = nextRuntimeRunIdByTeam[request.teamName];
delete nextRuntimeRunIdByTeam[request.teamName];
const nextIgnoredRunIds = Object.fromEntries(
Object.entries(state.ignoredProvisioningRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
const nextIgnoredRuntimeRunIds = previousRuntimeRunId
? {
...Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
),
[previousRuntimeRunId]: request.teamName,
}
: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
);
return {
provisioningRuns: cleaned,
provisioningErrorByTeam: nextErrors,
memberSpawnStatusesByTeam: nextSpawnStatuses,
activeToolsByTeam: nextActiveTools,
finishedVisibleByTeam: nextFinishedVisible,
toolHistoryByTeam: nextToolHistory,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
ignoredProvisioningRunIds: nextIgnoredRunIds,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
};
});
@ -1970,6 +2047,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.currentRuntimeRunIdByTeam,
[request.teamName]: response.runId,
},
ignoredRuntimeRunIds: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== request.teamName
)
),
};
});
try {
@ -2037,18 +2119,37 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.ignoredProvisioningRunIds,
[runId]: existing.teamName,
};
const nextIgnoredRuntimeRunIds =
state.currentRuntimeRunIdByTeam[existing.teamName] === runId
? {
...state.ignoredRuntimeRunIds,
[runId]: existing.teamName,
}
: state.ignoredRuntimeRunIds;
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
if (isCanonicalRun) {
delete nextSpawnStatuses[existing.teamName];
}
const nextActiveTools = { ...state.activeToolsByTeam };
const nextFinishedVisible = { ...state.finishedVisibleByTeam };
const nextToolHistory = { ...state.toolHistoryByTeam };
if (isCanonicalRun) {
delete nextActiveTools[existing.teamName];
delete nextFinishedVisible[existing.teamName];
delete nextToolHistory[existing.teamName];
}
return {
provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
memberSpawnStatusesByTeam: nextSpawnStatuses,
activeToolsByTeam: nextActiveTools,
finishedVisibleByTeam: nextFinishedVisible,
toolHistoryByTeam: nextToolHistory,
ignoredProvisioningRunIds: nextIgnoredRunIds,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
};
});
},
@ -2061,6 +2162,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (get().ignoredProvisioningRunIds[progress.runId] === progress.teamName) {
return;
}
if (get().ignoredRuntimeRunIds[progress.runId] === progress.teamName) {
return;
}
const floor = get().provisioningStartedAtFloorByTeam[progress.teamName];
if (floor && progress.startedAt < floor) {
@ -2140,6 +2244,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
...state.currentRuntimeRunIdByTeam,
[progress.teamName]: progress.runId,
},
ignoredRuntimeRunIds: Object.fromEntries(
Object.entries(state.ignoredRuntimeRunIds).filter(
([, teamName]) => teamName !== progress.teamName
)
),
provisioningErrorByTeam: nextErrors,
provisioningSnapshotByTeam: nextSnapshots,
};

View file

@ -304,6 +304,42 @@ export interface ToolCallMeta {
name: string;
/** Human-readable preview extracted from input args, e.g. "index.ts", "grep -r foo" */
preview?: string;
/** Optional runtime tool_use identifier when available. */
toolUseId?: string;
}
export type ToolActivitySource = 'runtime' | 'inbox';
export type ToolActivityState = 'running' | 'complete' | 'error';
/** Live or recently finished tool activity for one team member. */
export interface ActiveToolCall {
memberName: string;
toolUseId: string;
toolName: string;
preview?: string;
startedAt: string;
state: ToolActivityState;
source: ToolActivitySource;
finishedAt?: string;
resultPreview?: string;
}
/** Renderer-facing event payload for tool lifecycle updates. */
export interface ToolActivityEventPayload {
action: 'start' | 'finish' | 'reset';
activity?: {
memberName: string;
toolUseId: string;
toolName: string;
preview?: string;
startedAt: string;
source: ToolActivitySource;
};
memberName?: string;
toolUseId?: string;
finishedAt?: string;
resultPreview?: string;
isError?: boolean;
}
export interface InboxMessage {
@ -534,6 +570,7 @@ export interface TeamChangeEvent {
| 'lead-activity'
| 'lead-context'
| 'lead-message'
| 'tool-activity'
| 'process'
| 'member-spawn';
teamName: string;