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:
parent
11506c6ea8
commit
f286468dac
10 changed files with 917 additions and 39 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 stop→launch.
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue